Compare commits

..
Author SHA1 Message Date
Peter Steinberger 7146326c56 fix: use local auth for gateway probe (#1011) (thanks @ivanrvpereira) 2026-01-16 19:04:47 +00:00
Ivan Pereira 1a99be7450 test(security): add coverage for gateway probe auth selection 2026-01-16 19:02:58 +00:00
Ivan Pereira 05c2256324 fix(security): resolve local auth for gateway probe 2026-01-16 19:02:58 +00:00
Shadow 026cf1130e Changelog: note Discord skill truncation 2026-01-16 10:03:53 -06:00
Wilkins bb14b19922 fix: truncate skill command descriptions to 100 chars for Discord (#1018)
* fix: truncate skill command descriptions to 100 chars for Discord

Discord slash commands have a 100 character limit for descriptions.
Skill descriptions were not being truncated, causing command registration
to fail with an empty error from the Discord API.

* style: format

* style: format
2026-01-16 10:01:59 -06:00
Peter Steinberger 0d6af15d1c feat: add user-invocable skill commands 2026-01-16 12:10:29 +00:00
Peter Steinberger eda9410bce fix: stabilize docker test suite 2026-01-16 11:47:14 +00:00
Peter Steinberger a51ed8a5dd fix(cli): auto-update global installs 2026-01-16 11:45:37 +00:00
Peter Steinberger 19bcbf85df fix: scope whatsapp self-chat response prefix 2026-01-16 10:54:11 +00:00
Peter Steinberger f49d0e5476 fix: expand exec abort/timeout coverage 2026-01-16 10:43:22 +00:00
Peter Steinberger 9c4c9c5edd chore: release 2026.1.15 2026-01-16 10:37:30 +00:00
0f34255359 chore: ignore local identity files (#1001) (thanks @gerardward2007)
* chore: ignore local identity files (IDENTITY.md, USER.md)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: ignore local identity files (#1001) (thanks @gerardward2007)

* chore: format session status tool

* chore: format bash exec background abort test

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 10:30:04 +00:00
tosh-hamburg de5fb65cb8 fix: docker-setup fails on Synology because of problem with bun (#1002) 2026-01-16 10:03:56 +00:00
Roshan Singh e773f84e39 fix: keep background exec aborts from killing sessions (#1000) (thanks @roshanasingh4)
When exec returns early in background mode, the tool-call AbortSignal can fire and previously caused killProcessTree(SIGKILL). Ignore abort after yielding/backgrounding so background sessions keep running.
2026-01-16 10:01:39 +00:00
Peter Steinberger b969c216fc docs: refresh unreleased highlights 2026-01-16 09:51:27 +00:00
Peter Steinberger f51a4d2aca docs: sort unreleased changelog 2026-01-16 09:46:12 +00:00
Peter Steinberger 30b3a9de30 fix: drop oauth status from session status 2026-01-16 09:39:12 +00:00
Peter Steinberger 0391f6553b fix: correct minimax usage + show reset 2026-01-16 09:36:45 +00:00
Peter Steinberger d0c986c4f0 feat: warn on weak model tiers 2026-01-16 09:34:37 +00:00
Peter Steinberger 384028e12e refactor: unify session reset helper 2026-01-16 09:33:39 +00:00
Peter Steinberger 4c14d6c8db chore: format web monitor inbox tests 2026-01-16 09:26:18 +00:00
Peter Steinberger 6fa437613b fix: delete transcripts on role ordering reset 2026-01-16 09:20:16 +00:00
Peter Steinberger 949fa1051f fix: wire markdown variant renderer 2026-01-16 09:19:25 +00:00
Peter Steinberger 4965727f39 chore: run format and fix sandbox browser timeouts 2026-01-16 09:18:58 +00:00
Peter Steinberger 7c34883267 refactor: consolidate chat markdown rendering 2026-01-16 09:16:43 +00:00
Peter Steinberger 072c3dc55c fix: suppress WhatsApp pairing replies for historical DMs 2026-01-16 09:10:44 +00:00
Peter Steinberger 83b3875131 docs: add control UI unauthorized FAQ hint 2026-01-16 09:07:20 +00:00
Peter Steinberger a35083808c chore: update macOS package lock 2026-01-16 09:06:23 +00:00
Peter Steinberger 3d3ec9d972 docs: add browserless remote CDP example 2026-01-16 09:05:30 +00:00
Peter Steinberger 9838a2850f fix: reset sessions after role ordering conflicts 2026-01-16 09:04:04 +00:00
Peter Steinberger 6c6bc6ff1c fix: add control UI auth guidance 2026-01-16 09:03:02 +00:00
Peter Steinberger 1791c1a765 feat: render native chat markdown via Textual 2026-01-16 09:02:27 +00:00
Peter Steinberger 6e53c061ff fix: tune remote CDP timeouts 2026-01-16 09:01:25 +00:00
Peter Steinberger 1773f8aea2 fix: upgrade ws to wss for https CDP 2026-01-16 08:44:08 +00:00
Peter Steinberger 52bdf57743 Merge pull request #880 from mkbehr/openai-image-gen-fix
fix: Make openai-image-gen skill use appropriate defaults per model.
2026-01-16 08:42:13 +00:00
Peter Steinberger b3ab24eb8e fix: align image-gen defaults (#880) (thanks @mkbehr) 2026-01-16 08:41:23 +00:00
Michael BehrandClaude Opus 4.5 6ac1c1d6ea fix(openai-image-gen): use correct file extension for output format
When --output-format is specified for GPT models, save files with
the correct extension (.webp, .jpeg, or .png) instead of always
using .png.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:37:57 +00:00
Michael BehrandClaude Opus 4.5 7655a501d0 feat(openai-image-gen): add model-specific parameter support
- Auto-detect model and apply appropriate defaults for size/quality
- Add --background, --output-format, and --style parameters
- Enforce dall-e-3 count=1 limitation with automatic adjustment
- Omit quality parameter for dall-e-2 (not supported)
- Document model-specific parameters and supported values

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:37:57 +00:00
Peter Steinberger 3b1b14b0b1 Merge pull request #895 from mukhtharcm/feat/chrome-browser-improvements
feat(browser): add support for authenticated remote browser profiles
2026-01-16 08:32:02 +00:00
Peter Steinberger bf15c87d2b fix: support authenticated remote CDP URLs (#895) (thanks @mukhtharcm) 2026-01-16 08:31:51 +00:00
Peter Steinberger 2dae4d382f Merge pull request #860 from nachoiacovino/feat/telegram-custom-commands
feat(telegram): support custom commands in config
2026-01-16 08:26:26 +00:00
Peter Steinberger e9a47a02d1 fix: stabilize macOS audio test and default browser detection 2026-01-16 08:25:39 +00:00
Peter SteinbergerandNacho Iacovino 929666a8c8 fix: add telegram custom commands (#860) (thanks @nachoiacovino)
Co-authored-by: Nacho Iacovino <50103937+nachoiacovino@users.noreply.github.com>
2026-01-16 08:22:09 +00:00
Muhammed Mukhthar CMandPeter Steinberger cd409e5667 fix: exclude google-antigravity from history downgrade hack (#894)
* Agent: exclude google-antigravity from history downgrade hack

* Lint: fix formatting in test

* Lint: formatting and unused vars in test

* fix: preserve google-antigravity tool calls (#894) (thanks @mukhtharcm)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-16 08:14:56 +00:00
Muhammed Mukhthar CM 8e80823b03 test(browser): fix missing getHeadersWithAuth mock in server tests 2026-01-16 08:11:00 +00:00
Muhammed Mukhthar CM 319afd192d feat(browser): add support for remote playwright websocket auth 2026-01-16 08:11:00 +00:00
Muhammed Mukhthar CM 6e0daf0936 feat(browser): add support for authenticated remote CDP profiles 2026-01-16 08:10:32 +00:00
129 changed files with 20604 additions and 16009 deletions

No files matched your search

+2
View File
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
.vscode/
IDENTITY.md
USER.md
+43 -14
View File
@@ -1,49 +1,78 @@
# Changelog
## 2026.1.15 (unreleased)
## 2026.1.16 (unreleased)
### Highlights
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
### Breaking
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
### Changes
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
- Skills: add user-invocable skill commands with sanitized names and an opt-out for model invocation.
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
- TUI: show provider/model labels for the active session and default model.
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
### Fixes
- WhatsApp: default response prefix only for self-chat, using identity name when set.
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
- Status: restore usage summary line for current provider when no OAuth profiles exist.
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
- Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts.
- Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira.
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
## 2026.1.14-1
+2
View File
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
ENV CLAWDBOT_PREFER_PNPM=1
RUN pnpm ui:install
RUN pnpm ui:build
+81 -17
View File
@@ -2,6 +2,87 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5998</sparkle:version>
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
<h3>Highlights</h3>
<ul>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
</ul>
<h3>Changes</h3>
<ul>
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
<li>TUI: show provider/model labels for the active session and default model.</li>
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</li>
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
<li>Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
</item>
<item>
<title>2026.1.14-1</title>
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
@@ -190,22 +271,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
<item>
<title>2026.1.11-3</title>
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5212</sparkle:version>
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
<h3>Fixes</h3>
<ul>
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
</item>
</channel>
</rss>
+2 -1
View File
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
Sources/Voice/VoiceWakeManager.swift
Sources/Voice/VoiceWakePreferences.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
+1 -1
View File
@@ -2,7 +2,7 @@ name: Clawdbot
options:
bundleIdPrefix: com.clawdbot
deploymentTarget:
iOS: "17.0"
iOS: "18.0"
xcodeVersion: "16.0"
settings:
+28 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
"pins" : [
{
"identity" : "elevenlabskit",
@@ -73,6 +73,15 @@
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
@@ -144,6 +153,24 @@
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3"
}
},
{
"identity" : "swiftui-math",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swiftui-math",
"state" : {
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
"version" : "0.1.0"
}
},
{
"identity" : "textual",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
"version" : "0.2.0"
}
}
],
"version" : 3
@@ -191,8 +191,8 @@ extension CronJobEditor {
func applyDeleteAfterRun(
to root: inout [String: Any],
scheduleKind: ScheduleKind? = nil,
deleteAfterRun: Bool? = nil
) {
deleteAfterRun: Bool? = nil)
{
let resolvedSchedule = scheduleKind ?? self.scheduleKind
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
if resolvedSchedule == .at {
@@ -395,7 +395,7 @@ extension InstancesSettings {
host: "phone",
ip: "10.0.0.3",
version: "2.0.0",
platform: "iOS 17.2",
platform: "iOS 18.0",
deviceFamily: "iPhone",
modelIdentifier: nil,
lastInputSeconds: 35,
@@ -446,7 +446,7 @@ extension InstancesSettings {
_ = view.platformIcon("watchOS 10")
_ = view.platformIcon("unknown 1.0")
_ = view.prettyPlatform("macOS 14.2")
_ = view.prettyPlatform("iOS 17")
_ = view.prettyPlatform("iOS 18")
_ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ")
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
host: "gateway",
ip: "10.0.0.4",
version: "3.0.0",
platform: "iOS 17",
platform: "iOS 18",
deviceFamily: nil,
modelIdentifier: nil,
lastInputSeconds: nil,
@@ -8,7 +8,7 @@ import Testing
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
defer { _ = TalkAudioPlayer.shared.stop() }
_ = try await withTimeout(seconds: 2.0) {
_ = try await withTimeout(seconds: 4.0) {
await TalkAudioPlayer.shared.play(data: wav)
}
@@ -27,7 +27,7 @@ import Testing
await Task.yield()
_ = await TalkAudioPlayer.shared.play(data: wav)
_ = try await withTimeout(seconds: 2.0) {
_ = try await withTimeout(seconds: 4.0) {
await first.value
}
#expect(true)
+9 -2
View File
@@ -5,7 +5,7 @@ import PackageDescription
let package = Package(
name: "ClawdbotKit",
platforms: [
.iOS(.v17),
.iOS(.v18),
.macOS(.v15),
],
products: [
@@ -14,6 +14,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
],
targets: [
.target(
@@ -29,7 +30,13 @@ let package = Package(
]),
.target(
name: "ClawdbotChatUI",
dependencies: ["ClawdbotKit"],
dependencies: [
"ClawdbotKit",
.product(
name: "Textual",
package: "textual",
condition: .when(platforms: [.macOS, .iOS])),
],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
@@ -0,0 +1,51 @@
import Foundation
enum ChatMarkdownPreprocessor {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Result {
let cleaned: String
let images: [InlineImage]
}
static func preprocess(markdown raw: String) -> Result {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return Result(cleaned: raw, images: [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return Result(cleaned: raw, images: []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return Result(cleaned: normalized, images: images.reversed())
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import Textual
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
case standard
case compact
}
@MainActor
struct ChatMarkdownRenderer: View {
enum Context {
case user
case assistant
}
let text: String
let context: Context
let variant: ChatMarkdownVariant
let font: Font
let textColor: Color
var body: some View {
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
StructuredText(markdown: processed.cleaned)
.modifier(ChatMarkdownStyle(
variant: self.variant,
context: self.context,
font: self.font,
textColor: self.textColor))
if !processed.images.isEmpty {
InlineImageList(images: processed.images)
}
}
}
}
private struct ChatMarkdownStyle: ViewModifier {
let variant: ChatMarkdownVariant
let context: ChatMarkdownRenderer.Context
let font: Font
let textColor: Color
func body(content: Content) -> some View {
Group {
if self.variant == .compact {
content.textual.structuredTextStyle(.default)
} else {
content.textual.structuredTextStyle(.gitHub)
}
}
.font(self.font)
.foregroundStyle(self.textColor)
.textual.inlineStyle(self.inlineStyle)
.textual.textSelection(.enabled)
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))
.link(.foregroundColor(linkColor))
}
}
@MainActor
private struct InlineImageList: View {
let images: [ChatMarkdownPreprocessor.InlineImage]
var body: some View {
ForEach(images, id: \.id) { item in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
@@ -1,114 +0,0 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
struct Block: Identifiable {
enum Kind: Equatable {
case text
case code(language: String?)
}
let id = UUID()
let kind: Kind
let text: String
}
struct SplitResult {
let blocks: [Block]
let images: [InlineImage]
}
static func split(markdown raw: String) -> SplitResult {
let extracted = self.extractInlineImages(from: raw)
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
return SplitResult(blocks: blocks, images: extracted.images)
}
private static func splitCodeBlocks(from raw: String) -> [Block] {
var blocks: [Block] = []
var buffer: [String] = []
var inCode = false
var codeLang: String?
var codeLines: [String] = []
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.hasPrefix("```") {
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
codeLines.removeAll(keepingCapacity: true)
inCode = false
codeLang = nil
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
buffer.removeAll(keepingCapacity: true)
inCode = true
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
if codeLang?.isEmpty == true { codeLang = nil }
}
continue
}
if inCode {
codeLines.append(line)
} else {
buffer.append(line)
}
}
if inCode {
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
} else {
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty {
blocks.append(Block(kind: .text, text: text))
}
}
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
}
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
guard let re = try? NSRegularExpression(pattern: pattern) else {
return (raw, [])
}
let ns = raw as NSString
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
if matches.isEmpty { return (raw, []) }
var images: [InlineImage] = []
var cleaned = raw
for match in matches.reversed() {
guard match.numberOfRanges >= 3 else { continue }
let label = ns.substring(with: match.range(at: 1))
let dataURL = ns.substring(with: match.range(at: 2))
let image: ClawdbotPlatformImage? = {
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
let b64 = String(dataURL[dataURL.index(after: comma)...])
guard let data = Data(base64Encoded: b64) else { return nil }
return ClawdbotPlatformImage(data: data)
}()
images.append(InlineImage(label: label, image: image))
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
let end = cleaned.index(start, offsetBy: match.range.length)
cleaned.replaceSubrange(start..<end, with: "")
}
let normalized = cleaned
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (normalized, images.reversed())
}
}
@@ -137,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape {
struct ChatMessageBubble: View {
let message: ClawdbotChatMessage
let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color?
var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
ChatMessageBody(
message: self.message,
isUser: self.isUser,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
@@ -154,6 +160,7 @@ private struct ChatMessageBody: View {
let message: ClawdbotChatMessage
let isUser: Bool
let style: ClawdbotChatView.Style
let markdownVariant: ChatMarkdownVariant
let userAccent: Color?
var body: some View {
@@ -169,39 +176,14 @@ private struct ChatMessageBody: View {
isUser: self.isUser)
}
} else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
ChatMarkdownRenderer(
text: text,
context: .user,
variant: self.markdownVariant,
font: .system(size: 14),
textColor: textColor)
} else {
ChatAssistantTextBody(text: text)
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
}
if !self.inlineAttachments.isEmpty {
@@ -505,10 +487,11 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ChatAssistantTextBody(text: self.text)
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
}
.padding(12)
.background(
@@ -612,114 +595,22 @@ private struct TypingDots: View {
}
}
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View {
let text: String
let markdownVariant: ChatMarkdownVariant
var body: some View {
let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
ChatMarkdownRenderer(
text: segment.text,
context: .assistant,
variant: self.markdownVariant,
font: font,
textColor: ClawdbotChatTheme.assistantText)
}
}
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let language, !language.isEmpty {
Text(language)
.font(.caption2.monospaced())
.foregroundStyle(.secondary)
}
Text(self.code)
.font(.system(size: 13, weight: .regular, design: .monospaced))
.foregroundStyle(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@@ -14,6 +14,7 @@ public struct ClawdbotChatView: View {
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool
private let style: Style
private let markdownVariant: ChatMarkdownVariant
private let userAccent: Color?
private enum Layout {
@@ -42,11 +43,13 @@ public struct ClawdbotChatView: View {
viewModel: ClawdbotChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard,
markdownVariant: ChatMarkdownVariant = .standard,
userAccent: Color? = nil)
{
self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
self.markdownVariant = markdownVariant
self.userAccent = userAccent
}
@@ -151,7 +154,11 @@ public struct ClawdbotChatView: View {
@ViewBuilder
private var messageListRows: some View {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
ChatMessageBubble(
message: msg,
style: self.style,
markdownVariant: self.markdownVariant,
userAccent: self.userAccent)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
@@ -172,7 +179,7 @@ public struct ClawdbotChatView: View {
}
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text)
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -0,0 +1,20 @@
import Testing
@testable import ClawdbotChatUI
@Suite("ChatMarkdownPreprocessor")
struct ChatMarkdownPreprocessorTests {
@Test func extractsDataURLImages() {
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
let markdown = """
Hello
![Pixel](data:image/png;base64,\(base64))
"""
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
#expect(result.cleaned == "Hello")
#expect(result.images.count == 1)
#expect(result.images.first?.image != nil)
}
}
+23
View File
@@ -101,6 +101,29 @@ group messages, so use admin if you need full visibility.
- Raw HTML from models is escaped to avoid Telegram parse errors.
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
## Commands (native + custom)
Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup.
You can add custom commands to the menu via config:
```json5
{
channels: {
telegram: {
customCommands: [
{ command: "backup", description: "Git backup" },
{ command: "generate", description: "Create an image" }
]
}
}
}
```
Notes:
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
## Limits
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
+3 -5
View File
@@ -82,15 +82,13 @@ When the wizard asks for your personal WhatsApp number, enter the phone you will
"selfChatMode": true,
"dmPolicy": "allowlist",
"allowFrom": ["+15551234567"]
},
"messages": {
"responsePrefix": "[clawdbot]"
}
}
```
Tip: set `messages.responsePrefix` explicitly if you want a consistent bot prefix
on outbound replies.
Self-chat replies default to `[{identity.name}]` when set (otherwise `[clawdbot]`)
if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
the prefix (use `""` to remove it).
### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable)
+8 -1
View File
@@ -871,6 +871,7 @@ Notes:
- `commands.text: false` disables parsing chat messages for commands.
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
- `channels.telegram.customCommands` adds extra Telegram bot menu entries. Names are normalized; conflicts with native commands are ignored.
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
@@ -929,6 +930,10 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
}
}
},
customCommands: [
{ command: "backup", description: "Git backup" },
{ command: "generate", description: "Create an image" }
],
historyLimit: 50, // include last N group messages as context (0 disables)
replyToMode: "first", // off | first | all
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
@@ -1266,7 +1271,9 @@ See [Messages](/concepts/messages) for queueing, sessions, and streaming context
`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across channels unless already present.
If `messages.responsePrefix` is unset, no prefix is applied by default.
If `messages.responsePrefix` is unset, no prefix is applied by default. WhatsApp self-chat
replies are the exception: they default to `[{identity.name}]` when set, otherwise
`[clawdbot]`, so same-phone conversations stay legible.
Set it to `"auto"` to derive `[{identity.name}]` for the routed agent (when set).
#### Template variables
+9
View File
@@ -161,6 +161,15 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
### Model strength (security note)
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
Recommendations:
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
## Reasoning & verbose output in groups
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
+2
View File
@@ -1355,9 +1355,11 @@ Facts (from code):
Fix:
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
- If you don’t have a token yet: `clawdbot doctor --generate-gateway-token`.
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
- Still stuck? Run `clawdbot status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
+40
View File
@@ -59,6 +59,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
enabled: true, // default: true
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
defaultProfile: "chrome",
color: "#FF4500",
headless: false,
@@ -79,6 +81,8 @@ Notes:
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
the default browser ports shift to stay in the same “family” (control = gateway + 2).
- `cdpUrl` defaults to `controlUrl + 1` when unset.
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
@@ -154,6 +158,42 @@ Example:
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
directly to a Chromium-based browser instance without a remote control server.
Remote CDP URLs can include auth:
- Query tokens (e.g., `https://provider.example?token=<token>`)
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
to the CDP WebSocket. Prefer environment variables or secrets managers for
tokens instead of committing them to config files.
### Browserless (hosted remote CDP)
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
Browserless region endpoint and authenticate with your API key.
Example:
```json5
{
browser: {
enabled: true,
defaultProfile: "browserless",
remoteCdpTimeoutMs: 2000,
remoteCdpHandshakeTimeoutMs: 4000,
profiles: {
browserless: {
cdpUrl: "https://production-sfo.browserless.io?token=<BROWSERLESS_API_KEY>",
color: "#00AA00"
}
}
}
}
```
Notes:
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
- Choose the region endpoint that matches your Browserless account (see their docs).
### Running the control server on the browser machine
Run a standalone browser control server (recommended when your Gateway is remote):
+2
View File
@@ -80,6 +80,8 @@ Notes:
- Use `{baseDir}` in instructions to reference the skill folder path.
- Optional frontmatter keys:
- `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.clawdbot.homepage`).
- `user-invocable` — `true|false` (default: `true`). When `true`, the skill is exposed as a user slash command.
- `disable-model-invocation` — `true|false` (default: `false`). When `true`, the skill is excluded from the model prompt (still available via user invocation).
## Gating (load-time filters)
+1
View File
@@ -96,6 +96,7 @@ Notes:
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage vs cost (what shows where)
+31 -28
View File
@@ -323,38 +323,41 @@ run_profile() {
local workspace="$3"
local agent_model_provider="$4" # "openai"|"anthropic"
echo "==> Onboard ($profile)"
if [[ "$agent_model_provider" == "openai" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice openai-api-key \
--openai-api-key "$OPENAI_API_KEY" \
--gateway-port "$port" \
echo "==> Onboard ($profile)"
if [[ "$agent_model_provider" == "openai" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice openai-api-key \
--openai-api-key "$OPENAI_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice token \
--token-provider anthropic \
--token "$ANTHROPIC_API_TOKEN" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
elif [[ -n "$ANTHROPIC_API_TOKEN" ]]; then
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice token \
--token-provider anthropic \
--token "$ANTHROPIC_API_TOKEN" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
else
clawdbot --profile "$profile" onboard \
--non-interactive \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
else
clawdbot --profile "$profile" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
--gateway-port "$port" \
--gateway-bind loopback \
--gateway-auth token \
--workspace "$workspace" \
--skip-health
+26 -21
View File
@@ -69,15 +69,20 @@ if [[ "$*" == *"enable-linger"* ]]; then
fi
exit 0
LOGINCTL
chmod +x /tmp/clawdbot-bin/loginctl
# Install the npm-global variant from the local /app source.
pkg_tgz="$(npm pack --silent /app)"
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js"
chmod +x /tmp/clawdbot-bin/loginctl
# Install the npm-global variant from the local /app source.
# `npm pack` can emit script output; keep only the tarball name.
pkg_tgz="$(npm pack --silent /app | tail -n 1 | tr -d '\r')"
if [ ! -f "/app/$pkg_tgz" ]; then
echo "npm pack failed (expected /app/$pkg_tgz)"
exit 1
fi
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js"
assert_entrypoint() {
local unit_path="$1"
@@ -126,17 +131,17 @@ LOGINCTL
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"node $git_entry doctor --repair --force" \
"$git_entry"
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"node $git_entry doctor --repair --force" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_entry daemon install --force" \
"$git_entry" \
"$npm_bin doctor --repair --force" \
"$npm_entry"
run_flow \
"git-to-npm" \
"node $git_entry daemon install --force" \
"$git_entry" \
"$npm_bin doctor --repair --force" \
"$npm_entry"
'
+62 -20
View File
@@ -42,6 +42,38 @@ TRASH
printf "%b" "$payload" >&3 2>/dev/null || true
}
wait_for_log() {
local needle="$1"
local timeout_s="${2:-45}"
local needle_compact
needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
local start_s
start_s="$(date +%s)"
while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if NEEDLE="$needle_compact" node --input-type=module -e "
import fs from \"node:fs\";
const file = process.env.WIZARD_LOG_PATH;
const needle = process.env.NEEDLE ?? \"\";
let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
process.exit(text.includes(needle) ? 0 : 1);
"; then
return 0
fi
fi
if [ $(( $(date +%s) - start_s )) -ge "$timeout_s" ]; then
echo "Timeout waiting for log: $needle"
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
tail -n 140 "$WIZARD_LOG_PATH" || true
fi
return 1
fi
sleep 0.2
done
}
start_gateway() {
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
@@ -81,6 +113,8 @@ TRASH
input_fifo="$(mktemp -u "/tmp/clawdbot-onboard-${case_name}.XXXXXX")"
mkfifo "$input_fifo"
local log_path="/tmp/clawdbot-onboard-${case_name}.log"
WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts.
script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$!
@@ -135,36 +169,44 @@ TRASH
}
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
}
send_reset_config_only() {
# 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
# 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_channels_flow() {
# Configure channels via configure wizard.
send $'"'"'\r'"'"' 1.0
send "" 1.5
# Mode (default Configure channels)
send $'"'"'\r'"'"' 0.8
send "" 1.0
# Configure chat channels now? -> No
send $'"'"'n\r'"'"' 0.6
# Prompts are interactive; notes are not. Use conservative delays to stay in sync.
# Where will the Gateway run? -> Local (default)
send $'"'"'\r'"'"' 1.2
# Channels mode -> Configure/link (default)
send $'"'"'\r'"'"' 1.5
# Select a channel -> Finished (last option; clack wraps on Up)
send $'"'"'\e[A\r'"'"' 2.0
# Keep stdin open until wizard exits.
send "" 2.5
}
send_skills_flow() {
# Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.0
send "" 1.2
send $'"'"'n\r'"'"' 0.6
send $'"'"'\r'"'"' 1.2
send "" 1.0
# Configure skills now? -> No
send $'"'"'n\r'"'"' 1.2
send "" 2.0
}
run_case_local_basic() {
@@ -257,7 +299,7 @@ NODE
export HOME="$home_dir"
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node dist/index.js onboard --non-interactive \
node dist/index.js onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
+11 -2
View File
@@ -45,10 +45,19 @@ function which(cmd) {
}
function resolveRunner() {
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
if (!preferPnpm) {
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
}
const pnpm = which("pnpm");
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
if (preferPnpm) {
// Fallback to bun if pnpm not found even when preferring pnpm
const bun = which("bun");
if (bun) return { cmd: bun, kind: "bun" };
}
return null;
}
+41 -1
View File
@@ -19,13 +19,53 @@ open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; e
Useful flags:
```bash
# GPT image models with various options
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
# DALL-E 3 (note: count is automatically limited to 1)
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
# DALL-E 2
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
```
## Model-Specific Parameters
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
### Size
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
- Default: `1024x1024`
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
- Default: `1024x1024`
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
- Default: `1024x1024`
### Quality
- **GPT image models**: `auto`, `high`, `medium`, or `low`
- Default: `high`
- **dall-e-3**: `hd` or `standard`
- Default: `standard`
- **dall-e-2**: `standard` only
- Default: `standard`
### Other Notable Differences
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
- **GPT image models** support additional parameters:
- `--background`: `transparent`, `opaque`, or `auto` (default)
- `--output-format`: `png` (default), `jpeg`, or `webp`
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
## Output
- `*.png` images
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
- `prompts.json` (prompt → file mapping)
- `index.html` (thumbnail gallery)
+74 -17
View File
@@ -62,24 +62,53 @@ def pick_prompts(count: int) -> list[str]:
return prompts
def get_model_defaults(model: str) -> tuple[str, str]:
"""Return (default_size, default_quality) for the given model."""
if model == "dall-e-2":
# quality will be ignored
return ("1024x1024", "standard")
elif model == "dall-e-3":
return ("1024x1024", "standard")
else:
# GPT image or future models
return ("1024x1024", "high")
def request_images(
api_key: str,
prompt: str,
model: str,
size: str,
quality: str,
background: str = "",
output_format: str = "",
style: str = "",
) -> dict:
url = "https://api.openai.com/v1/images/generations"
body = json.dumps(
{
"model": model,
"prompt": prompt,
"size": size,
"quality": quality,
"n": 1,
"response_format": "b64_json",
}
).encode("utf-8")
args = {
"model": model,
"prompt": prompt,
"size": size,
"n": 1,
}
# Quality parameter - dall-e-2 doesn't accept this parameter
if model != "dall-e-2":
args["quality"] = quality
if model.startswith("dall-e"):
args["response_format"] = "b64_json"
if model.startswith("gpt-image"):
if background:
args["background"] = background
if output_format:
args["output_format"] = output_format
if model == "dall-e-3" and style:
args["style"] = style
body = json.dumps(args).encode("utf-8")
req = urllib.request.Request(
url,
method="POST",
@@ -136,8 +165,11 @@ def main() -> int:
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
ap.add_argument("--size", default="1024x1024", help="Image size (e.g. 1024x1024, 1536x1024).")
ap.add_argument("--quality", default="high", help="Image quality (varies by model).")
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
args = ap.parse_args()
@@ -146,21 +178,46 @@ def main() -> int:
print("Missing OPENAI_API_KEY", file=sys.stderr)
return 2
# Apply model-specific defaults if not specified
default_size, default_quality = get_model_defaults(args.model)
size = args.size or default_size
quality = args.quality or default_quality
count = args.count
if args.model == "dall-e-3" and count > 1:
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
count = 1
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
out_dir.mkdir(parents=True, exist_ok=True)
prompts = [args.prompt] * args.count if args.prompt else pick_prompts(args.count)
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
# Determine file extension based on output format
if args.model.startswith("gpt-image") and args.output_format:
file_ext = args.output_format
else:
file_ext = "png"
items: list[dict] = []
for idx, prompt in enumerate(prompts, start=1):
print(f"[{idx}/{len(prompts)}] {prompt}")
res = request_images(api_key, prompt, args.model, args.size, args.quality)
res = request_images(
api_key,
prompt,
args.model,
size,
quality,
args.background,
args.output_format,
args.style,
)
b64 = res.get("data", [{}])[0].get("b64_json")
if not b64:
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
png = base64.b64decode(b64)
filename = f"{idx:03d}-{slugify(prompt)[:40]}.png"
(out_dir / filename).write_bytes(png)
image_bytes = base64.b64decode(b64)
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
(out_dir / filename).write_bytes(image_bytes)
items.append({"prompt": prompt, "file": filename})
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
@@ -0,0 +1,138 @@
import { afterEach, expect, test } from "vitest";
import { createExecTool } from "./bash-tools.exec";
import {
getFinishedSession,
getSession,
resetProcessRegistryForTests,
} from "./bash-process-registry";
import { killProcessTree } from "./shell-utils";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
afterEach(() => {
resetProcessRegistryForTests();
});
test("background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) killProcessTree(pid);
}
});
test("background exec still times out after tool signal abort", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{
command: 'node -e "setTimeout(() => {}, 5000)"',
background: true,
timeout: 0.2,
},
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + 2000;
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) killProcessTree(pid);
}
});
test("yielded background exec is not killed when tool signal aborts", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const abortController = new AbortController();
const result = await tool.execute(
"toolcall",
{ command: 'node -e "setTimeout(() => {}, 5000)"', yieldMs: 5 },
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
await sleep(150);
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
const pid = running?.pid;
if (pid) killProcessTree(pid);
}
});
test("yielded background exec still times out", async () => {
const tool = createExecTool({ allowBackground: true, backgroundMs: 10 });
const result = await tool.execute("toolcall", {
command: 'node -e "setTimeout(() => {}, 5000)"',
yieldMs: 5,
timeout: 0.2,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
let finished = getFinishedSession(sessionId);
const deadline = Date.now() + 2000;
while (!finished && Date.now() < deadline) {
await sleep(20);
finished = getFinishedSession(sessionId);
}
const running = getSession(sessionId);
try {
expect(finished?.status).toBe("failed");
} finally {
const pid = running?.pid;
if (pid) killProcessTree(pid);
}
});
+12 -5
View File
@@ -262,21 +262,28 @@ export function createExecTool(
fn();
};
const onAbort = () => {
// Tool-call abort should not kill backgrounded sessions; timeouts still must.
const onAbortSignal = () => {
if (yielded || session.backgrounded) return;
killSession(session);
};
if (signal?.aborted) onAbort();
// Timeouts always terminate, even for backgrounded sessions.
const onTimeout = () => {
timedOut = true;
killSession(session);
};
if (signal?.aborted) onAbortSignal();
else if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
signal.addEventListener("abort", onAbortSignal, { once: true });
}
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
if (effectiveTimeout > 0) {
timeoutTimer = setTimeout(() => {
timedOut = true;
onAbort();
onTimeout();
}, effectiveTimeout * 1000);
}
@@ -99,6 +99,7 @@ describe("session_status tool", () => {
expect(details.ok).toBe(true);
expect(details.statusText).toContain("Clawdbot");
expect(details.statusText).toContain("🧠 Model:");
expect(details.statusText).not.toContain("OAuth/token status");
});
it("errors for unknown session keys", async () => {
+26
View File
@@ -62,6 +62,11 @@ function isModelNotFoundErrorMessage(raw: string): boolean {
return false;
}
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
const msg = raw.toLowerCase();
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
}
function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim();
if (!trimmed) return fallback;
@@ -371,6 +376,18 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
if (
ok.text.length === 0 &&
allowNotFoundSkip &&
(model.provider === "google-antigravity" || model.provider === "openai-codex")
) {
skipped.push({
model: id,
reason: "no text returned (provider returned empty content)",
});
logProgress(`${progressLabel}: skip (empty response)`);
break;
}
expect(ok.text.length).toBeGreaterThan(0);
logProgress(`${progressLabel}: done`);
break;
@@ -416,6 +433,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (rate limit)`);
break;
}
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isChatGPTUsageLimitErrorMessage(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break;
}
logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message });
break;
+1 -3
View File
@@ -58,9 +58,7 @@ export function stripThoughtSignatures<T>(
if (!block || typeof block !== "object") return block;
const rec = block as ContentBlockWithSignature;
const stripSnake = shouldStripSignature(rec.thought_signature);
const stripCamel = includeCamelCase
? shouldStripSignature(rec.thoughtSignature)
: false;
const stripCamel = includeCamelCase ? shouldStripSignature(rec.thoughtSignature) : false;
if (!stripSnake && !stripCamel) {
return block;
}
@@ -162,7 +162,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
id: "call_1",
name: "read",
arguments: { path: "/tmp/foo" },
thoughtSignature: "{\"id\":1}",
thoughtSignature: '{"id":1}',
},
{
type: "toolCall",
@@ -192,7 +192,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
{ type: "text", text: "ok" },
{
type: "text",
text: "[Tool Call: read (ID: call_1)]\nArguments: {\n \"path\": \"/tmp/foo\"\n}",
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
},
{
type: "toolCall",
@@ -261,4 +261,64 @@ describe("runEmbeddedPiAgent", () => {
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
}, 20_000);
it("returns role ordering error when session ends with a user turn", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user 1" }],
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "seed assistant" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "mock-1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
});
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user 2" }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
});
expect(result.meta.error?.kind).toBe("role_ordering");
expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i);
expect(result.payloads?.[0]?.text).toContain("Message ordering conflict");
});
});
@@ -11,9 +11,7 @@ vi.mock("./pi-embedded-helpers.js", async () => {
...actual,
isGoogleModelApi: vi.fn(),
downgradeGeminiHistory: vi.fn(),
sanitizeSessionMessagesImages: vi
.fn()
.mockImplementation(async (msgs) => msgs),
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
};
});
@@ -30,9 +28,7 @@ describe("sanitizeSessionHistory", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(
async (msgs) => msgs,
);
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
// Default mock implementation
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
if (!msgs) return [];
+10 -7
View File
@@ -82,12 +82,13 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
return violations;
}
export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TResult = unknown>(
params: {
tools: AgentTool<TSchemaType, TResult>[];
provider: string;
},
): AgentTool<TSchemaType, TResult>[] {
export function sanitizeToolsForGoogle<
TSchemaType extends TSchema = TSchema,
TResult = unknown,
>(params: {
tools: AgentTool<TSchemaType, TResult>[];
provider: string;
}): AgentTool<TSchemaType, TResult>[] {
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
return params.tools;
}
@@ -95,7 +96,9 @@ export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TR
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
return {
...tool,
parameters: cleanToolSchemaForGemini(tool.parameters as Record<string, unknown>) as TSchemaType,
parameters: cleanToolSchemaForGemini(
tool.parameters as Record<string, unknown>,
) as TSchemaType,
};
});
}
+2
View File
@@ -274,6 +274,8 @@ export async function runEmbeddedPiAgent(
provider,
model: model.id,
},
systemPromptReport: attempt.systemPromptReport,
error: { kind: "role_ordering", message: errorText },
},
};
}
+1 -1
View File
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
aborted?: boolean;
systemPromptReport?: SessionSystemPromptReport;
error?: {
kind: "context_overflow" | "compaction_failure";
kind: "context_overflow" | "compaction_failure" | "role_ordering";
message: string;
};
};
+1 -4
View File
@@ -22,10 +22,7 @@ export type SubscribeEmbeddedPiSessionParams = {
blockReplyChunking?: BlockReplyChunking;
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAssistantMessageStart?: () => void | Promise<void>;
onAgentEvent?: (evt: {
stream: string;
data: Record<string, unknown>;
}) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
enforceFinalTag?: boolean;
};
+2
View File
@@ -51,6 +51,8 @@ function buildSandboxBrowserResolvedConfig(params: {
cdpProtocol: "http",
cdpHost,
cdpIsLoopback: true,
remoteCdpTimeoutMs: 1500,
remoteCdpHandshakeTimeoutMs: 3000,
color: DEFAULT_CLAWD_BROWSER_COLOR,
executablePath: undefined,
headless: params.headless,
@@ -0,0 +1,92 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildWorkspaceSkillCommandSpecs } from "./skills.js";
async function writeSkill(params: {
dir: string;
name: string;
description: string;
frontmatterExtra?: string;
}) {
const { dir, name, description, frontmatterExtra } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}
${frontmatterExtra ?? ""}
---
# ${name}
`,
"utf-8",
);
}
describe("buildWorkspaceSkillCommandSpecs", () => {
it("sanitizes and de-duplicates command names", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
await writeSkill({
dir: path.join(workspaceDir, "skills", "hello-world"),
name: "hello-world",
description: "Hello world skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "hello_world"),
name: "hello_world",
description: "Hello underscore skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "help"),
name: "help",
description: "Help skill",
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "hidden"),
name: "hidden-skill",
description: "Hidden skill",
frontmatterExtra: "user-invocable: false",
});
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
reservedNames: new Set(["help"]),
});
const names = commands.map((entry) => entry.name).sort();
expect(names).toEqual(["hello_world", "hello_world_2", "help_2"]);
expect(commands.find((entry) => entry.skillName === "hidden-skill")).toBeUndefined();
});
it("truncates descriptions longer than 100 characters for Discord compatibility", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const longDescription =
"This is a very long description that exceeds Discord's 100 character limit for slash command descriptions and should be truncated";
await writeSkill({
dir: path.join(workspaceDir, "skills", "long-desc"),
name: "long-desc",
description: longDescription,
});
await writeSkill({
dir: path.join(workspaceDir, "skills", "short-desc"),
name: "short-desc",
description: "Short description",
});
const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
});
const longCmd = commands.find((entry) => entry.skillName === "long-desc");
const shortCmd = commands.find((entry) => entry.skillName === "short-desc");
expect(longCmd?.description.length).toBeLessThanOrEqual(100);
expect(longCmd?.description.endsWith("…")).toBe(true);
expect(shortCmd?.description).toBe("Short description");
});
});
@@ -9,15 +9,17 @@ async function _writeSkill(params: {
name: string;
description: string;
metadata?: string;
frontmatterExtra?: string;
body?: string;
}) {
const { dir, name, description, metadata, body } = params;
const { dir, name, description, metadata, frontmatterExtra, body } = params;
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, "SKILL.md"),
`---
name: ${name}
description: ${description}${metadata ? `\nmetadata: ${metadata}` : ""}
${frontmatterExtra ?? ""}
---
${body ?? `# ${name}\n`}
@@ -38,4 +40,31 @@ describe("buildWorkspaceSkillSnapshot", () => {
expect(snapshot.prompt).toBe("");
expect(snapshot.skills).toEqual([]);
});
it("omits disable-model-invocation skills from the prompt", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
await _writeSkill({
dir: path.join(workspaceDir, "skills", "visible-skill"),
name: "visible-skill",
description: "Visible skill",
});
await _writeSkill({
dir: path.join(workspaceDir, "skills", "hidden-skill"),
name: "hidden-skill",
description: "Hidden skill",
frontmatterExtra: "disable-model-invocation: true",
});
const snapshot = buildWorkspaceSkillSnapshot(workspaceDir, {
managedSkillsDir: path.join(workspaceDir, ".managed"),
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
});
expect(snapshot.prompt).toContain("visible-skill");
expect(snapshot.prompt).not.toContain("hidden-skill");
expect(snapshot.skills.map((skill) => skill.name).sort()).toEqual([
"hidden-skill",
"visible-skill",
]);
});
});
+2
View File
@@ -16,6 +16,7 @@ export {
export type {
ClawdbotSkillMetadata,
SkillEligibilityContext,
SkillCommandSpec,
SkillEntry,
SkillInstallSpec,
SkillSnapshot,
@@ -24,6 +25,7 @@ export type {
export {
buildWorkspaceSkillSnapshot,
buildWorkspaceSkillsPrompt,
buildWorkspaceSkillCommandSpecs,
filterWorkspaceSkillEntries,
loadWorkspaceSkillEntries,
resolveSkillsPromptForRun,
+26
View File
@@ -5,6 +5,7 @@ import type {
ParsedSkillFrontmatter,
SkillEntry,
SkillInstallSpec,
SkillInvocationPolicy,
} from "./types.js";
function stripQuotes(value: string): string {
@@ -79,6 +80,19 @@ function getFrontmatterValue(frontmatter: ParsedSkillFrontmatter, key: string):
return typeof raw === "string" ? raw : undefined;
}
function parseFrontmatterBool(value: string | undefined, fallback: boolean): boolean {
if (!value) return fallback;
const normalized = value.trim().toLowerCase();
if (!normalized) return fallback;
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
return true;
}
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
return false;
}
return fallback;
}
export function resolveClawdbotMetadata(
frontmatter: ParsedSkillFrontmatter,
): ClawdbotSkillMetadata | undefined {
@@ -121,6 +135,18 @@ export function resolveClawdbotMetadata(
}
}
export function resolveSkillInvocationPolicy(
frontmatter: ParsedSkillFrontmatter,
): SkillInvocationPolicy {
return {
userInvocable: parseFrontmatterBool(getFrontmatterValue(frontmatter, "user-invocable"), true),
disableModelInvocation: parseFrontmatterBool(
getFrontmatterValue(frontmatter, "disable-model-invocation"),
false,
),
};
}
export function resolveSkillKey(skill: Skill, entry?: SkillEntry): string {
return entry?.clawdbot?.skillKey ?? skill.name;
}
+12
View File
@@ -26,6 +26,17 @@ export type ClawdbotSkillMetadata = {
install?: SkillInstallSpec[];
};
export type SkillInvocationPolicy = {
userInvocable: boolean;
disableModelInvocation: boolean;
};
export type SkillCommandSpec = {
name: string;
skillName: string;
description: string;
};
export type SkillsInstallPreferences = {
preferBrew: boolean;
nodeManager: "npm" | "pnpm" | "yarn" | "bun";
@@ -37,6 +48,7 @@ export type SkillEntry = {
skill: Skill;
frontmatter: ParsedSkillFrontmatter;
clawdbot?: ClawdbotSkillMetadata;
invocation?: SkillInvocationPolicy;
};
export type SkillEligibilityContext = {
+89 -3
View File
@@ -11,11 +11,16 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
import { resolveBundledSkillsDir } from "./bundled-dir.js";
import { shouldIncludeSkill } from "./config.js";
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
import {
parseFrontmatter,
resolveClawdbotMetadata,
resolveSkillInvocationPolicy,
} from "./frontmatter.js";
import { serializeByKey } from "./serialize.js";
import type {
ParsedSkillFrontmatter,
SkillEligibilityContext,
SkillCommandSpec,
SkillEntry,
SkillSnapshot,
} from "./types.js";
@@ -43,6 +48,36 @@ function filterSkillEntries(
return filtered;
}
const SKILL_COMMAND_MAX_LENGTH = 32;
const SKILL_COMMAND_FALLBACK = "skill";
// Discord command descriptions must be ≤100 characters
const SKILL_COMMAND_DESCRIPTION_MAX_LENGTH = 100;
function sanitizeSkillCommandName(raw: string): string {
const normalized = raw
.toLowerCase()
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const trimmed = normalized.slice(0, SKILL_COMMAND_MAX_LENGTH);
return trimmed || SKILL_COMMAND_FALLBACK;
}
function resolveUniqueSkillCommandName(base: string, used: Set<string>): string {
const normalizedBase = base.toLowerCase();
if (!used.has(normalizedBase)) return base;
for (let index = 2; index < 1000; index += 1) {
const suffix = `_${index}`;
const maxBaseLength = Math.max(1, SKILL_COMMAND_MAX_LENGTH - suffix.length);
const trimmedBase = base.slice(0, maxBaseLength);
const candidate = `${trimmedBase}${suffix}`;
const candidateKey = candidate.toLowerCase();
if (!used.has(candidateKey)) return candidate;
}
const fallback = `${base.slice(0, Math.max(1, SKILL_COMMAND_MAX_LENGTH - 2))}_x`;
return fallback;
}
function loadSkillEntries(
workspaceDir: string,
opts?: {
@@ -114,6 +149,7 @@ function loadSkillEntries(
skill,
frontmatter,
clawdbot: resolveClawdbotMetadata(frontmatter),
invocation: resolveSkillInvocationPolicy(frontmatter),
};
});
return skillEntries;
@@ -139,7 +175,10 @@ export function buildWorkspaceSkillSnapshot(
opts?.skillFilter,
opts?.eligibility,
);
const resolvedSkills = eligible.map((entry) => entry.skill);
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const resolvedSkills = promptEntries.map((entry) => entry.skill);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
return {
@@ -172,8 +211,11 @@ export function buildWorkspaceSkillsPrompt(
opts?.skillFilter,
opts?.eligibility,
);
const promptEntries = eligible.filter(
(entry) => entry.invocation?.disableModelInvocation !== true,
);
const remoteNote = opts?.eligibility?.remote?.note?.trim();
return [remoteNote, formatSkillsForPrompt(eligible.map((entry) => entry.skill))]
return [remoteNote, formatSkillsForPrompt(promptEntries.map((entry) => entry.skill))]
.filter(Boolean)
.join("\n");
}
@@ -251,3 +293,47 @@ export function filterWorkspaceSkillEntries(
): SkillEntry[] {
return filterSkillEntries(entries, config);
}
export function buildWorkspaceSkillCommandSpecs(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
reservedNames?: Set<string>;
},
): SkillCommandSpec[] {
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
const eligible = filterSkillEntries(
skillEntries,
opts?.config,
opts?.skillFilter,
opts?.eligibility,
);
const userInvocable = eligible.filter((entry) => entry.invocation?.userInvocable !== false);
const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) {
used.add(reserved.toLowerCase());
}
const specs: SkillCommandSpec[] = [];
for (const entry of userInvocable) {
const base = sanitizeSkillCommandName(entry.skill.name);
const unique = resolveUniqueSkillCommandName(base, used);
used.add(unique.toLowerCase());
const rawDescription = entry.skill.description?.trim() || entry.skill.name;
const description =
rawDescription.length > SKILL_COMMAND_DESCRIPTION_MAX_LENGTH
? rawDescription.slice(0, SKILL_COMMAND_DESCRIPTION_MAX_LENGTH - 1) + "…"
: rawDescription;
specs.push({
name: unique,
skillName: entry.skill.name,
description,
});
}
return specs;
}
+12 -71
View File
@@ -1,6 +1,5 @@
import { Type } from "@sinclair/typebox";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
@@ -32,7 +31,6 @@ import {
formatUsageWindowSummary,
loadProviderUsageSummary,
resolveUsageProviderId,
type UsageProviderId,
} from "../../infra/provider-usage.js";
import {
buildAgentMainSessionKey,
@@ -283,48 +281,31 @@ export function createSessionStatusTool(opts?: {
defaultModel: DEFAULT_MODEL,
});
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const authHealth = buildAuthHealthSummary({
store: authStore,
cfg,
});
const oauthProfiles = authHealth.profiles.filter(
(profile) => profile.type === "oauth" || profile.type === "token",
);
const usageProviders = Array.from(
new Set(
oauthProfiles
.map((profile) => resolveUsageProviderId(profile.provider))
.filter((provider): provider is UsageProviderId => Boolean(provider)),
),
);
const usageByProvider = new Map<string, string>();
if (usageProviders.length > 0) {
const usageProvider = resolveUsageProviderId(providerForCard);
let usageLine: string | undefined;
if (usageProvider) {
try {
const usageSummary = await loadProviderUsageSummary({
timeoutMs: 3500,
providers: usageProviders,
providers: [usageProvider],
agentDir,
});
for (const snapshot of usageSummary.providers) {
const snapshot = usageSummary.providers.find((entry) => entry.provider === usageProvider);
if (snapshot) {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
});
if (formatted) usageByProvider.set(snapshot.provider, formatted);
if (formatted && !formatted.startsWith("error:")) {
usageLine = `📊 Usage: ${formatted}`;
}
}
} catch {
// ignore
}
}
const usageProvider = resolveUsageProviderId(providerForCard);
const usageLine =
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
: undefined;
const isGroup =
resolved.entry.chatType === "group" ||
resolved.entry.chatType === "room" ||
@@ -370,53 +351,13 @@ export function createSessionStatusTool(opts?: {
includeTranscriptUsage: false,
});
const authStatusLines = (() => {
if (oauthProfiles.length === 0) return [];
const formatStatus = (status: string) => {
if (status === "ok") return "ok";
if (status === "static") return "static";
if (status === "expiring") return "expiring";
if (status === "missing") return "unknown";
return "expired";
};
const profilesByProvider = new Map<string, typeof oauthProfiles>();
for (const profile of oauthProfiles) {
const current = profilesByProvider.get(profile.provider);
if (current) current.push(profile);
else profilesByProvider.set(profile.provider, [profile]);
}
const lines: string[] = ["OAuth/token status"];
for (const [provider, profiles] of profilesByProvider) {
const usageKey = resolveUsageProviderId(provider);
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
const usageSuffix = usage ? ` — usage: ${usage}` : "";
lines.push(`- ${provider}${usageSuffix}`);
for (const profile of profiles) {
const labelText = profile.label || profile.profileId;
const status = formatStatus(profile.status);
const expiry =
profile.status === "static"
? ""
: profile.expiresAt
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
: " expires unknown";
const source = profile.source !== "store" ? ` (${profile.source})` : "";
lines.push(` - ${labelText} ${status}${expiry}${source}`);
}
}
return lines;
})();
const fullStatusText =
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
return {
content: [{ type: "text", text: fullStatusText }],
content: [{ type: "text", text: statusText }],
details: {
ok: true,
sessionKey: resolved.key,
changedModel,
statusText: fullStatusText,
statusText,
},
};
},
+23
View File
@@ -45,6 +45,29 @@ describe("commands registry", () => {
expect(nativeDisabled.find((spec) => spec.name === "debug")).toBeFalsy();
});
it("appends skill commands when provided", () => {
const skillCommands = [
{
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
},
];
const commands = listChatCommandsForConfig(
{
commands: { config: false, debug: false },
},
{ skillCommands },
);
expect(commands.find((spec) => spec.nativeName === "demo_skill")).toBeTruthy();
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands },
);
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
});
it("detects known text commands", () => {
const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true);
+38 -11
View File
@@ -1,4 +1,5 @@
import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import { CHAT_COMMANDS, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
@@ -61,8 +62,24 @@ function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function listChatCommands(): ChatCommandDefinition[] {
return [...CHAT_COMMANDS];
function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatCommandDefinition[] {
if (!skillCommands || skillCommands.length === 0) return [];
return skillCommands.map((spec) => ({
key: `skill:${spec.skillName}`,
nativeName: spec.name,
description: spec.description,
textAliases: [`/${spec.name}`],
acceptsArgs: true,
argsParsing: "none",
scope: "both",
}));
}
export function listChatCommands(params?: {
skillCommands?: SkillCommandSpec[];
}): ChatCommandDefinition[] {
if (!params?.skillCommands?.length) return [...CHAT_COMMANDS];
return [...CHAT_COMMANDS, ...buildSkillCommandDefinitions(params.skillCommands)];
}
export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boolean {
@@ -72,23 +89,33 @@ export function isCommandEnabled(cfg: ClawdbotConfig, commandKey: string): boole
return true;
}
export function listChatCommandsForConfig(cfg: ClawdbotConfig): ChatCommandDefinition[] {
return CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
export function listChatCommandsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): ChatCommandDefinition[] {
const base = CHAT_COMMANDS.filter((command) => isCommandEnabled(cfg, command.key));
if (!params?.skillCommands?.length) return base;
return [...base, ...buildSkillCommandDefinitions(params.skillCommands)];
}
export function listNativeCommandSpecs(): NativeCommandSpec[] {
return CHAT_COMMANDS.filter((command) => command.scope !== "text" && command.nativeName).map(
(command) => ({
export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
description: command.description,
acceptsArgs: Boolean(command.acceptsArgs),
args: command.args,
}),
);
}));
}
export function listNativeCommandSpecsForConfig(cfg: ClawdbotConfig): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg)
export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({
name: command.nativeName ?? command.key,
@@ -63,6 +63,7 @@ export async function runAgentTurnWithFallback(params: {
shouldEmitToolResult: () => boolean;
pendingToolTasks: Set<Promise<void>>;
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
isHeartbeat: boolean;
sessionKey?: string;
getActiveSessionEntry: () => SessionEntry | undefined;
@@ -376,6 +377,17 @@ export async function runAgentTurnWithFallback(params: {
didResetAfterCompactionFailure = true;
continue;
}
if (embeddedError?.kind === "role_ordering") {
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
if (didReset) {
return {
kind: "final",
payload: {
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
},
};
}
}
break;
} catch (err) {
@@ -395,6 +407,17 @@ export async function runAgentTurnWithFallback(params: {
didResetAfterCompactionFailure = true;
continue;
}
if (isRoleOrderingError) {
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
if (didReset) {
return {
kind: "final",
payload: {
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
},
};
}
}
// Auto-recover from Gemini session corruption by resetting the session
if (
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import * as sessions from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import type { TemplateContext } from "../templating.js";
import type { GetReplyOptions } from "../types.js";
@@ -127,11 +128,14 @@ describe("runReplyAgent typing (heartbeat)", () => {
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const sessionEntry = { sessionId, updatedAt: Date.now() };
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => {
@@ -175,11 +179,14 @@ describe("runReplyAgent typing (heartbeat)", () => {
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const sessionEntry = { sessionId, updatedAt: Date.now() };
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock
.mockImplementationOnce(async () => ({
@@ -212,6 +219,59 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId);
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
if (prevStateDir) {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
} else {
delete process.env.CLAWDBOT_STATE_DIR;
}
}
});
it("resets the session after role ordering payloads", async () => {
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
try {
const sessionId = "session";
const storePath = path.join(stateDir, "sessions", "sessions.json");
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
const sessionStore = { main: sessionEntry };
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
await fs.writeFile(transcriptPath, "ok", "utf-8");
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
payloads: [{ text: "Message ordering conflict - please try again.", isError: true }],
meta: {
durationMs: 1,
error: {
kind: "role_ordering",
message: 'messages: roles must alternate between "user" and "assistant"',
},
},
}));
const { run } = createMinimalRun({
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
const res = await run();
const payload = Array.isArray(res) ? res[0] : res;
expect(payload).toMatchObject({
text: expect.stringContaining("Message ordering conflict"),
});
expect(payload.text?.toLowerCase()).toContain("reset");
expect(sessionStore.main.sessionId).not.toBe(sessionId);
await expect(fs.access(transcriptPath)).rejects.toBeDefined();
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally {
+45 -6
View File
@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
@@ -8,6 +9,7 @@ import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import {
resolveAgentIdFromSessionKey,
resolveSessionFilePath,
resolveSessionTranscriptPath,
type SessionEntry,
updateSessionStore,
@@ -209,11 +211,23 @@ export async function runReplyAgent(params: {
});
let responseUsageLine: string | undefined;
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> => {
type SessionResetOptions = {
failureLabel: string;
buildLogMessage: (nextSessionId: string) => string;
cleanupTranscripts?: boolean;
};
const resetSession = async ({
failureLabel,
buildLogMessage,
cleanupTranscripts,
}: SessionResetOptions): Promise<boolean> => {
if (!sessionKey || !activeSessionStore || !storePath) return false;
const prevEntry = activeSessionStore[sessionKey] ?? activeSessionEntry;
if (!prevEntry) return false;
const prevSessionId = cleanupTranscripts ? prevEntry.sessionId : undefined;
const nextSessionId = crypto.randomUUID();
const nextEntry: SessionEntry = {
...(activeSessionStore[sessionKey] ?? activeSessionEntry),
...prevEntry,
sessionId: nextSessionId,
updatedAt: Date.now(),
systemSent: false,
@@ -233,18 +247,42 @@ export async function runReplyAgent(params: {
});
} catch (err) {
defaultRuntime.error(
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
`Failed to persist session reset after ${failureLabel} (${sessionKey}): ${String(err)}`,
);
}
followupRun.run.sessionId = nextSessionId;
followupRun.run.sessionFile = nextSessionFile;
activeSessionEntry = nextEntry;
activeIsNewSession = true;
defaultRuntime.error(
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
);
defaultRuntime.error(buildLogMessage(nextSessionId));
if (cleanupTranscripts && prevSessionId) {
const transcriptCandidates = new Set<string>();
const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId });
if (resolved) transcriptCandidates.add(resolved);
transcriptCandidates.add(resolveSessionTranscriptPath(prevSessionId, agentId));
for (const candidate of transcriptCandidates) {
try {
fs.unlinkSync(candidate);
} catch {
// Best-effort cleanup.
}
}
}
return true;
};
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> =>
resetSession({
failureLabel: "compaction failure",
buildLogMessage: (nextSessionId) =>
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
});
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> =>
resetSession({
failureLabel: "role ordering conflict",
buildLogMessage: (nextSessionId) =>
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
cleanupTranscripts: true,
});
try {
const runOutcome = await runAgentTurnWithFallback({
commandBody,
@@ -260,6 +298,7 @@ export async function runReplyAgent(params: {
shouldEmitToolResult,
pendingToolTasks,
resetSessionAfterCompactionFailure,
resetSessionAfterRoleOrderingConflict,
isHeartbeat,
sessionKey,
getActiveSessionEntry: () => activeSessionEntry,
+8 -1
View File
@@ -1,4 +1,5 @@
import { logVerbose } from "../../globals.js";
import { listSkillCommandsForWorkspace } from "../skill-commands.js";
import { buildCommandsMessage, buildHelpMessage } from "../status.js";
import { buildStatusReply } from "./commands-status.js";
import { buildContextReply } from "./commands-context-report.js";
@@ -28,9 +29,15 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex
);
return { shouldContinue: false };
}
const skillCommands =
params.skillCommands ??
listSkillCommandsForWorkspace({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
return {
shouldContinue: false,
reply: { text: buildCommandsMessage(params.cfg) },
reply: { text: buildCommandsMessage(params.cfg, skillCommands) },
};
};
+1
View File
@@ -168,6 +168,7 @@ export async function buildStatusReply(params: {
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
});
if (formatted) usageByProvider.set(snapshot.provider, formatted);
}
+2
View File
@@ -1,6 +1,7 @@
import type { ChannelId } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { SkillCommandSpec } from "../../agents/skills.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
@@ -47,6 +48,7 @@ export type HandleCommandsParams = {
model: string;
contextTokens: number;
isGroup: boolean;
skillCommands?: SkillCommandSpec[];
};
export type CommandHandlerResult = {
@@ -11,6 +11,8 @@ import { isDirectiveOnly } from "./directive-handling.js";
import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
import { listSkillCommandsForWorkspace, resolveSkillCommandInvocation } from "../skill-commands.js";
import { logVerbose } from "../../globals.js";
export type InlineActionResult =
| { kind: "reply"; reply: ReplyPayload | ReplyPayload[] | undefined }
@@ -55,6 +57,7 @@ export async function handleInlineActions(params: {
contextTokens: number;
directiveAck?: ReplyPayload;
abortedLastRun: boolean;
skillFilter?: string[];
}): Promise<InlineActionResult> {
const {
ctx,
@@ -89,11 +92,47 @@ export async function handleInlineActions(params: {
contextTokens,
directiveAck,
abortedLastRun: initialAbortedLastRun,
skillFilter,
} = params;
let directives = initialDirectives;
let cleanedBody = initialCleanedBody;
const shouldLoadSkillCommands = command.commandBodyNormalized.startsWith("/");
const skillCommands = shouldLoadSkillCommands
? listSkillCommandsForWorkspace({
workspaceDir,
cfg,
skillFilter,
})
: [];
const skillInvocation =
allowTextCommands && skillCommands.length > 0
? resolveSkillCommandInvocation({
commandBodyNormalized: command.commandBodyNormalized,
skillCommands,
})
: null;
if (skillInvocation) {
if (!command.isAuthorizedSender) {
logVerbose(
`Ignoring /${skillInvocation.command.name} from unauthorized sender: ${command.senderId || "<unknown>"}`,
);
typing.cleanup();
return { kind: "reply", reply: undefined };
}
const promptParts = [
`Use the "${skillInvocation.command.skillName}" skill for this request.`,
skillInvocation.args ? `User input:\n${skillInvocation.args}` : null,
].filter((entry): entry is string => Boolean(entry));
const rewrittenBody = promptParts.join("\n\n");
ctx.Body = rewrittenBody;
sessionCtx.Body = rewrittenBody;
sessionCtx.BodyStripped = rewrittenBody;
cleanedBody = rewrittenBody;
}
const sendInlineReply = async (reply?: ReplyPayload) => {
if (!reply) return;
if (!opts?.onBlockReply) return;
@@ -174,6 +213,7 @@ export async function handleInlineActions(params: {
model,
contextTokens,
isGroup,
skillCommands,
});
if (inlineResult.reply) {
if (!inlineCommand.cleaned) {
@@ -235,6 +275,7 @@ export async function handleInlineActions(params: {
model,
contextTokens,
isGroup,
skillCommands,
});
if (!commandResult.shouldContinue) {
typing.cleanup();
+1
View File
@@ -203,6 +203,7 @@ export async function getReplyFromConfig(
contextTokens,
directiveAck,
abortedLastRun,
skillFilter: opts?.skillFilter,
});
if (inlineActionResult.kind === "reply") {
return inlineActionResult.reply;
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { resolveSkillCommandInvocation } from "./skill-commands.js";
describe("resolveSkillCommandInvocation", () => {
it("matches skill commands and parses args", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/demo_skill do the thing",
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
});
expect(invocation?.command.skillName).toBe("demo-skill");
expect(invocation?.args).toBe("do the thing");
});
it("returns null for unknown commands", () => {
const invocation = resolveSkillCommandInvocation({
commandBodyNormalized: "/unknown arg",
skillCommands: [{ name: "demo_skill", skillName: "demo-skill", description: "Demo" }],
});
expect(invocation).toBeNull();
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { ClawdbotConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { buildWorkspaceSkillCommandSpecs, type SkillCommandSpec } from "../agents/skills.js";
import { listChatCommands } from "./commands-registry.js";
function resolveReservedCommandNames(): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) reserved.add(command.nativeName.toLowerCase());
for (const alias of command.textAliases) {
const trimmed = alias.trim();
if (!trimmed.startsWith("/")) continue;
reserved.add(trimmed.slice(1).toLowerCase());
}
}
return reserved;
}
export function listSkillCommandsForWorkspace(params: {
workspaceDir: string;
cfg: ClawdbotConfig;
skillFilter?: string[];
}): SkillCommandSpec[] {
return buildWorkspaceSkillCommandSpecs(params.workspaceDir, {
config: params.cfg,
skillFilter: params.skillFilter,
eligibility: { remote: getRemoteSkillEligibility() },
reservedNames: resolveReservedCommandNames(),
});
}
export function resolveSkillCommandInvocation(params: {
commandBodyNormalized: string;
skillCommands: SkillCommandSpec[];
}): { command: SkillCommandSpec; args?: string } | null {
const trimmed = params.commandBodyNormalized.trim();
if (!trimmed.startsWith("/")) return null;
const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!match) return null;
const commandName = match[1]?.trim().toLowerCase();
if (!commandName) return null;
const command = params.skillCommands.find((entry) => entry.name.toLowerCase() === commandName);
if (!command) return null;
const args = match[2]?.trim();
return { command, args: args || undefined };
}
+16
View File
@@ -318,6 +318,22 @@ describe("buildCommandsMessage", () => {
expect(text).not.toContain("/config");
expect(text).not.toContain("/debug");
});
it("includes skill commands when provided", () => {
const text = buildCommandsMessage(
{
commands: { config: false, debug: false },
} as ClawdbotConfig,
[
{
name: "demo_skill",
skillName: "demo-skill",
description: "Demo skill",
},
],
);
expect(text).toContain("/demo_skill - Demo skill");
});
});
describe("buildHelpMessage", () => {
+8 -2
View File
@@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js";
import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
type AgentConfig = Partial<NonNullable<NonNullable<ClawdbotConfig["agents"]>["defaults"]>>;
@@ -352,9 +353,14 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
].join("\n");
}
export function buildCommandsMessage(cfg?: ClawdbotConfig): string {
export function buildCommandsMessage(
cfg?: ClawdbotConfig,
skillCommands?: SkillCommandSpec[],
): string {
const lines = ["ℹ️ Slash commands"];
const commands = cfg ? listChatCommandsForConfig(cfg) : listChatCommands();
const commands = cfg
? listChatCommandsForConfig(cfg, { skillCommands })
: listChatCommands({ skillCommands });
for (const command of commands) {
const primary = command.nativeName
? `/${command.nativeName}`
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
describe("cdp.helpers", () => {
it("preserves query params when appending CDP paths", () => {
const url = appendCdpPath("https://example.com?token=abc", "/json/version");
expect(url).toBe("https://example.com/json/version?token=abc");
});
it("appends paths under a base prefix", () => {
const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list");
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
});
it("adds basic auth headers when credentials are present", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com");
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
});
it("keeps preexisting authorization headers", () => {
const headers = getHeadersWithAuth("https://user:pass@example.com", {
Authorization: "Bearer token",
});
expect(headers.Authorization).toBe("Bearer token");
});
});
+44 -3
View File
@@ -28,6 +28,29 @@ export function isLoopbackHost(host: string) {
);
}
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
if (hasAuthHeader) return headers;
if (parsed.username || parsed.password) {
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
return { ...headers, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return headers;
}
export function appendCdpPath(cdpUrl: string, path: string): string {
const url = new URL(cdpUrl);
const basePath = url.pathname.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
url.pathname = `${basePath}${suffix}`;
return url.toString();
}
function createCdpSender(ws: WebSocket) {
let nextId = 1;
const pending = new Map<number, Pending>();
@@ -75,11 +98,12 @@ function createCdpSender(ws: WebSocket) {
return { send, closeWithError };
}
export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
} finally {
@@ -87,11 +111,28 @@ export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
}
}
export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} finally {
clearTimeout(t);
}
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: { headers?: Record<string, string> },
): Promise<T> {
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const ws = new WebSocket(wsUrl, {
handshakeTimeout: 5000,
...(Object.keys(headers).length ? { headers } : {}),
});
const { send, closeWithError } = createCdpSender(ws);
const openPromise = new Promise<void>((resolve, reject) => {
+16
View File
@@ -165,4 +165,20 @@ describe("cdp", () => {
);
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
});
it("propagates auth and query params onto normalized websocket URLs", () => {
const normalized = normalizeCdpWsUrl(
"ws://127.0.0.1:9222/devtools/browser/ABC",
"https://user:pass@example.com?token=abc",
);
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
});
it("upgrades ws to wss when CDP uses https", () => {
const normalized = normalizeCdpWsUrl(
"ws://production-sfo.browserless.io",
"https://production-sfo.browserless.io?token=abc",
);
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
});
});
+17 -3
View File
@@ -1,4 +1,6 @@
import { fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
const ws = new URL(wsUrl);
@@ -9,6 +11,16 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
if (cdpPort) ws.port = cdpPort;
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
}
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
ws.protocol = "wss:";
}
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
ws.username = cdp.username;
ws.password = cdp.password;
}
for (const [key, value] of cdp.searchParams.entries()) {
if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
}
return ws.toString();
}
@@ -68,8 +80,10 @@ export async function createTargetViaCdp(opts: {
cdpUrl: string;
url: string;
}): Promise<{ targetId: string }> {
const base = opts.cdpUrl.replace(/\/$/, "");
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`${base}/json/version`, 1500);
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
appendCdpPath(opts.cdpUrl, "/json/version"),
1500,
);
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
+19 -11
View File
@@ -24,10 +24,12 @@ describe("browser default executable detection", () => {
it("prefers default Chromium browser on macOS", async () => {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
return "com.google.Chrome";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.google.Chrome" },
]);
}
if (cmd === "/usr/bin/osascript" && argsStr.includes("POSIX path")) {
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
return "/Applications/Google Chrome.app";
}
if (cmd === "/usr/bin/defaults") {
@@ -35,9 +37,11 @@ describe("browser default executable detection", () => {
}
return "";
});
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
);
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes("com.apple.launchservices.secure.plist")) return true;
return value.includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
});
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
const exe = resolveBrowserExecutableForPlatform(
@@ -52,14 +56,18 @@ describe("browser default executable detection", () => {
it("falls back when default browser is non-Chromium on macOS", async () => {
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
const argsStr = Array.isArray(args) ? args.join(" ") : "";
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
return "com.apple.Safari";
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
return JSON.stringify([
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.apple.Safari" },
]);
}
return "";
});
vi.mocked(fs.existsSync).mockImplementation((p) =>
String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
);
vi.mocked(fs.existsSync).mockImplementation((p) => {
const value = String(p);
if (value.includes("com.apple.launchservices.secure.plist")) return true;
return value.includes("Google Chrome.app/Contents/MacOS/Google Chrome");
});
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
const exe = resolveBrowserExecutableForPlatform(
+62 -17
View File
@@ -95,12 +95,17 @@ function exists(filePath: string) {
}
}
function execText(command: string, args: string[], timeoutMs = 1200): string | null {
function execText(
command: string,
args: string[],
timeoutMs = 1200,
maxBuffer = 1024 * 1024,
): string | null {
try {
const output = execFileSync(command, args, {
timeout: timeoutMs,
encoding: "utf8",
maxBuffer: 1024 * 1024,
maxBuffer,
});
return String(output ?? "").trim() || null;
} catch {
@@ -114,7 +119,12 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
if (id.includes("edge")) return "edge";
if (id.includes("chromium")) return "chromium";
if (id.includes("canary")) return "canary";
if (id.includes("opera") || id.includes("vivaldi") || id.includes("yandex") || id.includes("thebrowser")) {
if (
id.includes("opera") ||
id.includes("vivaldi") ||
id.includes("yandex") ||
id.includes("thebrowser")
) {
return "chromium";
}
return "chrome";
@@ -126,13 +136,12 @@ function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
if (lower.includes("chromium")) return "chromium";
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) return "chromium";
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex"))
return "chromium";
return "chrome";
}
function detectDefaultChromiumExecutable(
platform: NodeJS.Platform,
): BrowserExecutable | null {
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
@@ -140,14 +149,12 @@ function detectDefaultChromiumExecutable(
}
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
const bundleId = execText("/usr/bin/osascript", [
"-e",
'id of application (path to default application for URL "http://example.com")',
]);
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId.trim())) return null;
const bundleId = detectDefaultBrowserBundleIdMac();
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) return null;
const appPathRaw = execText("/usr/bin/osascript", [
"-e",
'POSIX path of (path to default application for URL "http://example.com")',
`POSIX path of (path to application id "${bundleId}")`,
]);
if (!appPathRaw) return null;
const appPath = appPathRaw.trim().replace(/\/$/, "");
@@ -162,6 +169,45 @@ function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
}
function detectDefaultBrowserBundleIdMac(): string | null {
const plistPath = path.join(
os.homedir(),
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
);
if (!exists(plistPath)) return null;
const handlersRaw = execText(
"/usr/bin/plutil",
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
2000,
5 * 1024 * 1024,
);
if (!handlersRaw) return null;
let handlers: unknown;
try {
handlers = JSON.parse(handlersRaw);
} catch {
return null;
}
if (!Array.isArray(handlers)) return null;
const resolveScheme = (scheme: string) => {
let candidate: string | null = null;
for (const entry of handlers) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
if (record.LSHandlerURLScheme !== scheme) continue;
const role =
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
null;
if (role) candidate = role;
}
return candidate;
};
return resolveScheme("http") ?? resolveScheme("https");
}
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
const desktopId =
execText("xdg-settings", ["get", "default-web-browser"]) ||
@@ -185,8 +231,7 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
const progId = readWindowsProgId();
const command =
(progId ? readWindowsCommandForProgId(progId) : null) ||
readWindowsCommandForProgId("http");
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
if (!command) return null;
const expanded = expandWindowsEnvVars(command);
const exePath = extractWindowsExecutablePath(expanded);
@@ -243,7 +288,7 @@ function splitExecLine(line: string): string[] {
let quoteChar = "";
for (let i = 0; i < line.length; i += 1) {
const ch = line[i];
if ((ch === "\"" || ch === "'") && (!inQuotes || ch === quoteChar)) {
if ((ch === '"' || ch === "'") && (!inQuotes || ch === quoteChar)) {
if (inQuotes) {
inQuotes = false;
quoteChar = "";
@@ -300,7 +345,7 @@ function readWindowsCommandForProgId(progId: string): string | null {
function expandWindowsEnvVars(value: string): string {
return value.replace(/%([^%]+)%/g, (_match, name) => {
const key = String(name ?? "").trim();
return key ? process.env[key] ?? `%${key}%` : _match;
return key ? (process.env[key] ?? `%${key}%`) : _match;
});
}
+10 -4
View File
@@ -7,7 +7,8 @@ import WebSocket from "ws";
import { ensurePortAvailable } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { CONFIG_DIR } from "../utils.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
import { appendCdpPath } from "./cdp.helpers.js";
import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
@@ -71,9 +72,10 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const base = cdpUrl.replace(/\/$/, "");
const res = await fetch(`${base}/json/version`, {
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
const res = await fetch(versionUrl, {
signal: ctrl.signal,
headers: getHeadersWithAuth(versionUrl),
});
if (!res.ok) return null;
const data = (await res.json()) as ChromeVersion;
@@ -98,7 +100,11 @@ export async function getChromeWebSocketUrl(
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
const headers = getHeadersWithAuth(wsUrl);
const ws = new WebSocket(wsUrl, {
handshakeTimeout: timeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
});
const timer = setTimeout(
() => {
try {
+12
View File
@@ -19,6 +19,8 @@ describe("browser config", () => {
expect(clawd?.driver).toBe("clawd");
expect(clawd?.cdpPort).toBe(18800);
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:18800");
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
@@ -52,6 +54,16 @@ describe("browser config", () => {
expect(resolved.color).toBe("#FF4500");
});
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
});
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
});
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18791",
+14
View File
@@ -21,6 +21,8 @@ export type ResolvedBrowserConfig = {
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
remoteCdpTimeoutMs: number;
remoteCdpHandshakeTimeoutMs: number;
color: string;
executablePath?: string;
headless: boolean;
@@ -61,6 +63,11 @@ function normalizeHexColor(raw: string | undefined) {
return normalized.toUpperCase();
}
function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
return value < 0 ? fallback : value;
}
export function parseHttpUrl(raw: string, label: string) {
const trimmed = raw.trim();
const parsed = new URL(trimmed);
@@ -149,6 +156,11 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
);
const controlPort = controlInfo.port;
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
cfg?.remoteCdpHandshakeTimeoutMs,
Math.max(2000, remoteCdpTimeoutMs * 2),
);
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
@@ -206,6 +218,8 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
remoteCdpTimeoutMs,
remoteCdpHandshakeTimeoutMs,
color: defaultColor,
executablePath,
headless,
+3 -1
View File
@@ -8,6 +8,7 @@ import type {
} from "playwright-core";
import { chromium } from "playwright-core";
import { formatErrorMessage } from "../infra/errors.js";
import { getHeadersWithAuth } from "./cdp.helpers.js";
import { getChromeWebSocketUrl } from "./chrome.js";
export type BrowserConsoleMessage = {
@@ -268,7 +269,8 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
const timeout = 5000 + attempt * 2000;
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
const endpoint = wsUrl ?? normalized;
const browser = await chromium.connectOverCDP(endpoint, { timeout });
const headers = getHeadersWithAuth(endpoint);
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
cached = connected;
observeBrowser(browser);
+44 -16
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
isChromeReachable,
@@ -50,7 +50,8 @@ async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit):
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as T;
} finally {
@@ -62,7 +63,8 @@ async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promi
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} finally {
clearTimeout(t);
@@ -106,7 +108,7 @@ function createProfileContext(
webSocketDebuggerUrl?: string;
type?: string;
}>
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
>(appendCdpPath(profile.cdpUrl, "/json/list"));
return raw
.map((t) => ({
targetId: t.id ?? "",
@@ -146,8 +148,13 @@ function createProfileContext(
type?: string;
};
const base = profile.cdpUrl.replace(/\/$/, "");
const endpoint = `${base}/json/new?${encoded}`;
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
const endpoint = endpointUrl.search
? (() => {
endpointUrl.searchParams.set("url", url);
return endpointUrl.toString();
})()
: `${endpointUrl.toString()}?${encoded}`;
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
method: "PUT",
}).catch(async (err) => {
@@ -164,18 +171,41 @@ function createProfileContext(
targetId: created.id,
title: created.title ?? "",
url: created.url ?? url,
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
type: created.type,
};
};
const isReachable = async (timeoutMs = 300) => {
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
const resolveRemoteHttpTimeout = (timeoutMs: number | undefined) => {
if (profile.cdpIsLoopback) return timeoutMs ?? 300;
const resolved = state().resolved;
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs);
}
return resolved.remoteCdpTimeoutMs;
};
const isHttpReachable = async (timeoutMs = 300) => {
return await isChromeReachable(profile.cdpUrl, timeoutMs);
const resolveRemoteWsTimeout = (timeoutMs: number | undefined) => {
if (profile.cdpIsLoopback) {
const base = timeoutMs ?? 300;
return Math.max(200, Math.min(2000, base * 2));
}
const resolved = state().resolved;
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
return Math.max(Math.floor(timeoutMs) * 2, resolved.remoteCdpHandshakeTimeoutMs);
}
return resolved.remoteCdpHandshakeTimeoutMs;
};
const isReachable = async (timeoutMs?: number) => {
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
const wsTimeout = resolveRemoteWsTimeout(timeoutMs);
return await isChromeCdpReady(profile.cdpUrl, httpTimeout, wsTimeout);
};
const isHttpReachable = async (timeoutMs?: number) => {
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
return await isChromeReachable(profile.cdpUrl, httpTimeout);
};
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
@@ -327,7 +357,6 @@ function createProfileContext(
};
const focusTab = async (targetId: string): Promise<void> => {
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -336,13 +365,12 @@ function createProfileContext(
}
throw new Error("tab not found");
}
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
const profileState = getProfileState();
profileState.lastTargetId = resolved.targetId;
};
const closeTab = async (targetId: string): Promise<void> => {
const base = profile.cdpUrl.replace(/\/$/, "");
const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
@@ -351,7 +379,7 @@ function createProfileContext(
}
throw new Error("tab not found");
}
await fetchOk(`${base}/json/close/${resolved.targetId}`);
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`));
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
@@ -129,6 +129,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
File diff suppressed because it is too large. Load diff
+1 -27
View File
@@ -27,16 +27,6 @@ function setWhatsAppAllowFrom(cfg: ClawdbotConfig, allowFrom?: string[]): Clawdb
return mergeWhatsAppConfig(cfg, { allowFrom }, { unsetOnUndefined: ["allowFrom"] });
}
function setMessagesResponsePrefix(cfg: ClawdbotConfig, responsePrefix?: string): ClawdbotConfig {
return {
...cfg,
messages: {
...cfg.messages,
responsePrefix,
},
};
}
function setWhatsAppSelfChatMode(cfg: ClawdbotConfig, selfChatMode: boolean): ClawdbotConfig {
return mergeWhatsAppConfig(cfg, { selfChatMode });
}
@@ -65,7 +55,6 @@ async function promptWhatsAppAllowFrom(
const existingPolicy = cfg.channels?.whatsapp?.dmPolicy ?? "pairing";
const existingAllowFrom = cfg.channels?.whatsapp?.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
const existingResponsePrefix = cfg.messages?.responsePrefix;
if (options?.forceAllowlist) {
await prompter.note(
@@ -96,17 +85,8 @@ async function promptWhatsAppAllowFrom(
let next = setWhatsAppSelfChatMode(cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, unique);
if (existingResponsePrefix === undefined) {
next = setMessagesResponsePrefix(next, "[clawdbot]");
}
await prompter.note(
[
"Allowlist mode enabled.",
`- allowFrom includes ${normalized}`,
existingResponsePrefix === undefined
? "- responsePrefix set to [clawdbot]"
: "- responsePrefix left unchanged",
].join("\n"),
["Allowlist mode enabled.", `- allowFrom includes ${normalized}`].join("\n"),
"WhatsApp allowlist",
);
return next;
@@ -163,17 +143,11 @@ async function promptWhatsAppAllowFrom(
let next = setWhatsAppSelfChatMode(cfg, true);
next = setWhatsAppDmPolicy(next, "allowlist");
next = setWhatsAppAllowFrom(next, unique);
if (existingResponsePrefix === undefined) {
next = setMessagesResponsePrefix(next, "[clawdbot]");
}
await prompter.note(
[
"Personal phone mode enabled.",
"- dmPolicy set to allowlist (pairing skipped)",
`- allowFrom includes ${normalized}`,
existingResponsePrefix === undefined
? "- responsePrefix set to [clawdbot]"
: "- responsePrefix left unchanged",
].join("\n"),
"WhatsApp personal phone",
);
+5 -1
View File
@@ -238,7 +238,11 @@ export function registerConfigCli(program: Command) {
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
return;
}
if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") {
if (
typeof res.value === "string" ||
typeof res.value === "number" ||
typeof res.value === "boolean"
) {
defaultRuntime.log(String(res.value));
return;
}
+5 -1
View File
@@ -225,7 +225,11 @@ describe("daemon-cli coverage", () => {
});
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string };
const parsed = JSON.parse(jsonLine ?? "{}") as {
ok?: boolean;
action?: string;
result?: string;
};
expect(parsed.ok).toBe(true);
expect(parsed.action).toBe("install");
expect(parsed.result).toBe("installed");
+1 -4
View File
@@ -1,8 +1,5 @@
import type { Command } from "commander";
import {
listPairingChannels,
notifyPairingApproved,
} from "../channels/plugins/pairing.js";
import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js";
import { normalizeChannelId } from "../channels/plugins/index.js";
import { loadConfig } from "../config/config.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
+2 -6
View File
@@ -378,9 +378,7 @@ export function registerPluginsCli(program: Command) {
continue;
}
if (record.source !== "npm") {
defaultRuntime.log(
chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`),
);
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`));
continue;
}
if (!record.spec) {
@@ -412,9 +410,7 @@ export function registerPluginsCli(program: Command) {
if (currentVersion && probe.version && currentVersion === probe.version) {
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
} else {
defaultRuntime.log(
`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`,
);
defaultRuntime.log(`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`);
}
continue;
}
+15 -8
View File
@@ -28,6 +28,7 @@ const STEP_LABELS: Record<string, string> = {
"ui:build": "Building UI",
"clawdbot doctor": "Running doctor checks",
"git rev-parse HEAD (after)": "Verifying update",
"global update": "Updating via package manager",
};
function getStepLabel(step: UpdateStepInfo): string {
@@ -206,13 +207,11 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (result.reason === "not-git-install") {
defaultRuntime.log(
theme.warn(
"Skipped: this Clawdbot install isn't a git checkout. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.",
"Skipped: this Clawdbot install isn't a git checkout, and the package manager couldn't be detected. Update via your package manager, then run `clawdbot doctor` and `clawdbot daemon restart`.",
),
);
defaultRuntime.log(
theme.muted(
"Examples: `npm i -g clawdbot@latest`, `pnpm add -g clawdbot@latest`, or `bun add -g clawdbot@latest`",
),
theme.muted("Examples: `npm i -g clawdbot@latest` or `pnpm add -g clawdbot@latest`"),
);
}
defaultRuntime.exit(0);
@@ -251,9 +250,17 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
}
} else if (!opts.json) {
defaultRuntime.log("");
defaultRuntime.log(
theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."),
);
if (result.mode === "npm" || result.mode === "pnpm") {
defaultRuntime.log(
theme.muted(
"Tip: Run `clawdbot doctor`, then `clawdbot daemon restart` to apply updates to a running gateway.",
),
);
} else {
defaultRuntime.log(
theme.muted("Tip: Run `clawdbot daemon restart` to apply updates to a running gateway."),
);
}
}
}
@@ -276,7 +283,7 @@ Examples:
Notes:
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
- For global installs: use npm/pnpm/bun to reinstall (see docs/install/updating.md)
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
- Skips update if the working directory has uncommitted changes
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
+1 -4
View File
@@ -70,10 +70,7 @@ export async function maybeOfferUpdateBeforeDoctor(params: {
note(
[
"This install is not a git checkout.",
"Update via your package manager, then rerun doctor:",
"- npm i -g clawdbot@latest",
"- pnpm add -g clawdbot@latest",
"- bun add -g clawdbot@latest",
"Run `clawdbot update` to update via your package manager (npm/pnpm), then rerun doctor.",
].join("\n"),
"Update",
);
+1 -3
View File
@@ -372,8 +372,6 @@ async function resolveSshTarget(
});
if (!target) return { target: rawTarget, identity: identity ?? undefined };
const identityFile =
identity ??
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
undefined;
identity ?? config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? undefined;
return { target, identity: identityFile };
}
+5 -1
View File
@@ -424,7 +424,11 @@ export async function modelsStatusCommand(
timeoutMs: 3500,
});
for (const snapshot of usageSummary.providers) {
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 });
const formatted = formatUsageWindowSummary(snapshot, {
now: Date.now(),
maxWindows: 2,
includeResets: true,
});
if (formatted) {
usageByProvider.set(snapshot.provider, formatted);
}
+1 -3
View File
@@ -291,9 +291,7 @@ export async function statusAllCommand(
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";
const gatewayAuth = gatewayReachable
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
: "";
const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : "";
const gatewaySelfLine =
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
? [
+12 -12
View File
@@ -28,18 +28,18 @@ describe("config discord", () => {
dm: {
enabled: true,
allowFrom: ["steipete"],
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
groupEnabled: true,
groupChannels: ["clawd-dm"],
},
actions: {
emojiUploads: true,
stickerUploads: false,
channels: true,
},
guilds: {
"123": {
slug: "friends-of-clawd",
requireMention: false,
users: ["steipete"],
channels: {
general: { allow: true },
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { ClawdbotSchema } from "./zod-schema.js";
describe("telegram custom commands schema", () => {
it("normalizes custom commands", () => {
const res = ClawdbotSchema.safeParse({
channels: {
telegram: {
customCommands: [{ command: "/Backup", description: " Git backup " }],
},
},
});
expect(res.success).toBe(true);
if (!res.success) return;
expect(res.data.channels?.telegram?.customCommands).toEqual([
{ command: "backup", description: "Git backup" },
]);
});
it("rejects custom commands with invalid names", () => {
const res = ClawdbotSchema.safeParse({
channels: {
telegram: {
customCommands: [{ command: "Bad-Name", description: "Override status" }],
},
},
});
expect(res.success).toBe(false);
if (res.success) return;
expect(
res.error.issues.some(
(issue) =>
issue.path.join(".") === "channels.telegram.customCommands.0.command" &&
issue.message.includes("invalid"),
),
).toBe(true);
});
});
+7 -1
View File
@@ -167,6 +167,8 @@ const FIELD_LABELS: Record<string, string> = {
"commands.useAccessGroups": "Use Access Groups",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
"session.dmScope": "DM Session Scope",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"messages.ackReaction": "Ack Reaction Emoji",
@@ -175,6 +177,7 @@ const FIELD_LABELS: Record<string, string> = {
"talk.apiKey": "Talk API Key",
"channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram",
"channels.telegram.customCommands": "Telegram Custom Commands",
"channels.discord": "Discord",
"channels.slack": "Slack",
"channels.signal": "Signal",
@@ -303,7 +306,8 @@ const FIELD_HELP: Record<string, string> = {
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
"plugins.installs.*.installPath": "Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
"plugins.installs.*.installPath":
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
"plugins.installs.*.version": "Version recorded at install time (if available).",
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
"agents.defaults.model.primary": "Primary model (provider/model).",
@@ -348,6 +352,8 @@ const FIELD_HELP: Record<string, string> = {
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
"session.agentToAgent.maxPingPongTurns":
"Max reply-back turns between requester and target (0–5).",
"channels.telegram.customCommands":
"Additional Telegram bot menu commands (merged with native; conflicts ignored).",
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
"messages.ackReactionScope":
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
+93
View File
@@ -0,0 +1,93 @@
export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
export type TelegramCustomCommandInput = {
command?: string | null;
description?: string | null;
};
export type TelegramCustomCommandIssue = {
index: number;
field: "command" | "description";
message: string;
};
export function normalizeTelegramCommandName(value: string): string {
const trimmed = value.trim();
if (!trimmed) return "";
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
return withoutSlash.trim().toLowerCase();
}
export function normalizeTelegramCommandDescription(value: string): string {
return value.trim();
}
export function resolveTelegramCustomCommands(params: {
commands?: TelegramCustomCommandInput[] | null;
reservedCommands?: Set<string>;
checkReserved?: boolean;
checkDuplicates?: boolean;
}): {
commands: Array<{ command: string; description: string }>;
issues: TelegramCustomCommandIssue[];
} {
const entries = Array.isArray(params.commands) ? params.commands : [];
const reserved = params.reservedCommands ?? new Set<string>();
const checkReserved = params.checkReserved !== false;
const checkDuplicates = params.checkDuplicates !== false;
const seen = new Set<string>();
const resolved: Array<{ command: string; description: string }> = [];
const issues: TelegramCustomCommandIssue[] = [];
for (let index = 0; index < entries.length; index += 1) {
const entry = entries[index];
const normalized = normalizeTelegramCommandName(String(entry?.command ?? ""));
if (!normalized) {
issues.push({
index,
field: "command",
message: "Telegram custom command is missing a command name.",
});
continue;
}
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
issues.push({
index,
field: "command",
message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`,
});
continue;
}
if (checkReserved && reserved.has(normalized)) {
issues.push({
index,
field: "command",
message: `Telegram custom command "/${normalized}" conflicts with a native command.`,
});
continue;
}
if (checkDuplicates && seen.has(normalized)) {
issues.push({
index,
field: "command",
message: `Telegram custom command "/${normalized}" is duplicated.`,
});
continue;
}
const description = normalizeTelegramCommandDescription(String(entry?.description ?? ""));
if (!description) {
issues.push({
index,
field: "description",
message: `Telegram custom command "/${normalized}" is missing a description.`,
});
continue;
}
if (checkDuplicates) {
seen.add(normalized);
}
resolved.push({ command: normalized, description });
}
return { commands: resolved, issues };
}
+4
View File
@@ -21,6 +21,10 @@ export type BrowserConfig = {
controlToken?: string;
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
cdpUrl?: string;
/** Remote CDP HTTP timeout (ms). Default: 1500. */
remoteCdpTimeoutMs?: number;
/** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */
remoteCdpHandshakeTimeoutMs?: number;
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
color?: string;
/** Override the browser executable path (all platforms). */
+10
View File
@@ -14,6 +14,14 @@ export type TelegramActionConfig = {
deleteMessage?: boolean;
};
/** Custom command definition for Telegram bot menu. */
export type TelegramCustomCommand = {
/** Command name (without leading /). */
command: string;
/** Description shown in Telegram command menu. */
description: string;
};
export type TelegramAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -21,6 +29,8 @@ export type TelegramAccountConfig = {
capabilities?: string[];
/** Override native command registration for Telegram (bool or "auto"). */
commands?: ProviderCommandsConfig;
/** Custom commands to register in Telegram's command menu (merged with native). */
customCommands?: TelegramCustomCommand[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/**
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more