Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c46dfc8411 | ||
|
|
15b64ebfe8 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
026cf1130e | ||
|
|
bb14b19922 | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a | ||
|
|
0d6af15d1c | ||
|
|
eda9410bce | ||
|
|
a51ed8a5dd | ||
|
|
19bcbf85df | ||
|
|
f49d0e5476 | ||
|
|
9c4c9c5edd | ||
|
|
0f34255359 | ||
|
|
de5fb65cb8 | ||
|
|
e773f84e39 |
No files matched your search
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
+9
-2
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
@@ -16,8 +16,10 @@
|
||||
- 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.
|
||||
@@ -46,10 +48,11 @@
|
||||
- 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.
|
||||
- Fix: run UI install/build with pnpm only (Docker avoids bun failures). (#1002) — thanks @tosh-hamburg.
|
||||
- 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.
|
||||
@@ -67,6 +70,10 @@
|
||||
- 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.
|
||||
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
# Install Bun (required for build scripts)
|
||||
RUN curl -fsSL https://bun.sh/install | bash
|
||||
ENV PATH="/root/.bun/bin:${PATH}"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
@@ -21,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
|
||||
|
||||
|
||||
@@ -492,5 +492,5 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a>
|
||||
<a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
|
||||
<a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
+81
-17
@@ -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>
|
||||
@@ -257,30 +257,8 @@ final class AppState {
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
|
||||
@@ -385,14 +385,8 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
||||
case configMode
|
||||
case configRemoteURL
|
||||
case userDefaults
|
||||
case onboarding
|
||||
}
|
||||
|
||||
struct EffectiveConnectionMode: Sendable, Equatable {
|
||||
let mode: AppState.ConnectionMode
|
||||
let source: EffectiveConnectionModeSource
|
||||
}
|
||||
|
||||
enum ConnectionModeResolver {
|
||||
static func resolve(
|
||||
root: [String: Any],
|
||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||
{
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||
let configMode = configModeRaw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch configMode {
|
||||
case "local":
|
||||
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
||||
case "remote":
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
||||
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !remoteURL.isEmpty {
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
||||
}
|
||||
|
||||
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
||||
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
||||
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
||||
}
|
||||
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ConcurrencyExtras
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
private enum EnvOverrideWarningKind: Sendable {
|
||||
case token
|
||||
case password
|
||||
}
|
||||
|
||||
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||
!configPassword.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .password,
|
||||
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .token,
|
||||
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func warnEnvOverrideOnce(
|
||||
kind: EnvOverrideWarningKind,
|
||||
envVar: String,
|
||||
configKey: String)
|
||||
{
|
||||
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||
switch kind {
|
||||
case .token:
|
||||
guard !state.token else { return false }
|
||||
state.token = true
|
||||
return true
|
||||
case .password:
|
||||
guard !state.password else { return false }
|
||||
state.password = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
guard shouldWarn else { return }
|
||||
Self.staticLogger.warning(
|
||||
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
@@ -66,4 +73,70 @@ import Testing
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " local ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverTrimsConfigMode() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " remote ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "staging",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": " ws://umbrel:18789 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -21,3 +21,14 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
```bash
|
||||
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
```
|
||||
@@ -1271,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
|
||||
|
||||
@@ -146,6 +146,19 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
||||
</Card>
|
||||
|
||||
<Card title="Linear CLI" icon="terminal" href="https://github.com/Finesssee/linear-cli">
|
||||
**@NessZerra** • `devtools` `linear` `cli` `issues`
|
||||
|
||||
CLI for Linear that integrates with agentic workflows (Claude Code, Clawdbot). Manage issues, projects, and workflows from the terminal. First external PR merged!
|
||||
</Card>
|
||||
|
||||
<Card title="Beeper CLI" icon="message" href="https://github.com/blqke/beepcli">
|
||||
**@jules** • `messaging` `beeper` `cli` `automation`
|
||||
|
||||
Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API so agents can manage all your chats (iMessage, WhatsApp, etc.) in one place.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🤖 Automation & Workflows
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
'
|
||||
@@ -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 \
|
||||
|
||||
+40
-12
@@ -45,8 +45,19 @@ function which(cmd) {
|
||||
}
|
||||
|
||||
function resolveRunner() {
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
@@ -97,7 +108,7 @@ if (!action) {
|
||||
const runner = resolveRunner();
|
||||
if (!runner) {
|
||||
process.stderr.write(
|
||||
"Missing UI runner: install pnpm, then retry.\n",
|
||||
"Missing UI runner: install bun or pnpm, then retry.\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -118,15 +129,32 @@ if (action !== "install" && !script) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (action === "install") run(runner, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs = action === "build" ? ["install", "--prod"] : ["install"];
|
||||
runSync(runner, installArgs, installEnv);
|
||||
if (runner.kind === "bun") {
|
||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs =
|
||||
action === "build" ? ["install", "--production"] : ["install"];
|
||||
runSync(runner.cmd, installArgs, installEnv);
|
||||
}
|
||||
run(runner.cmd, ["run", script, ...rest]);
|
||||
}
|
||||
} else {
|
||||
if (action === "install") run(runner.cmd, ["install", ...rest]);
|
||||
else {
|
||||
if (!depsInstalled(action === "test" ? "test" : "build")) {
|
||||
const installEnv =
|
||||
action === "build"
|
||||
? { ...process.env, NODE_ENV: "production" }
|
||||
: process.env;
|
||||
const installArgs =
|
||||
action === "build" ? ["install", "--prod"] : ["install"];
|
||||
runSync(runner.cmd, installArgs, installEnv);
|
||||
}
|
||||
run(runner.cmd, ["run", script, ...rest]);
|
||||
}
|
||||
run(runner, ["run", script, ...rest]);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -290,9 +290,7 @@ export function createSessionStatusTool(opts?: {
|
||||
providers: [usageProvider],
|
||||
agentDir,
|
||||
});
|
||||
const snapshot = usageSummary.providers.find(
|
||||
(entry) => entry.provider === usageProvider,
|
||||
);
|
||||
const snapshot = usageSummary.providers.find((entry) => entry.provider === usageProvider);
|
||||
if (snapshot) {
|
||||
const formatted = formatUsageWindowSummary(snapshot, {
|
||||
now: Date.now(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -203,6 +203,7 @@ export async function getReplyFromConfig(
|
||||
contextTokens,
|
||||
directiveAck,
|
||||
abortedLastRun,
|
||||
skillFilter: opts?.skillFilter,
|
||||
});
|
||||
if (inlineActionResult.kind === "reply") {
|
||||
return inlineActionResult.reply;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,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",
|
||||
);
|
||||
|
||||
+15
-8
@@ -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,9 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return process.env.HOME ?? os.homedir();
|
||||
}
|
||||
@@ -21,3 +26,47 @@ export async function noteMacLaunchAgentOverrides() {
|
||||
].filter((line): line is string => Boolean(line));
|
||||
note(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
|
||||
async function launchctlGetenv(name: string): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" });
|
||||
const value = String(result.stdout ?? "").trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasConfigGatewayCreds(cfg: ClawdbotConfig): boolean {
|
||||
const localToken =
|
||||
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
|
||||
const localPassword =
|
||||
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway?.auth?.password.trim() : "";
|
||||
const remoteToken =
|
||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway?.remote?.token.trim() : "";
|
||||
const remotePassword =
|
||||
typeof cfg.gateway?.remote?.password === "string" ? cfg.gateway?.remote?.password.trim() : "";
|
||||
return Boolean(localToken || localPassword || remoteToken || remotePassword);
|
||||
}
|
||||
|
||||
export async function noteMacLaunchctlGatewayEnvOverrides(cfg: ClawdbotConfig) {
|
||||
if (process.platform !== "darwin") return;
|
||||
if (!hasConfigGatewayCreds(cfg)) return;
|
||||
|
||||
const envToken = await launchctlGetenv("CLAWDBOT_GATEWAY_TOKEN");
|
||||
const envPassword = await launchctlGetenv("CLAWDBOT_GATEWAY_PASSWORD");
|
||||
if (!envToken && !envPassword) return;
|
||||
|
||||
const lines = [
|
||||
"- launchctl environment overrides detected (can cause confusing unauthorized errors).",
|
||||
envToken ? "- `CLAWDBOT_GATEWAY_TOKEN` is set; it overrides config tokens." : undefined,
|
||||
envPassword
|
||||
? "- `CLAWDBOT_GATEWAY_PASSWORD` is set; it overrides config passwords."
|
||||
: undefined,
|
||||
"- Clear overrides and restart the app/gateway:",
|
||||
envToken ? " launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN" : undefined,
|
||||
envPassword ? " launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD" : undefined,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
|
||||
note(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "./doctor-gateway-services.js";
|
||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||
import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js";
|
||||
import {
|
||||
noteMacLaunchAgentOverrides,
|
||||
noteMacLaunchctlGatewayEnvOverrides,
|
||||
} from "./doctor-platform-notes.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js";
|
||||
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||
@@ -160,6 +163,7 @@ export async function doctorCommand(
|
||||
await maybeScanExtraGatewayServices(options);
|
||||
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
|
||||
await noteMacLaunchAgentOverrides();
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
@@ -116,7 +118,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
throw new Error("Failed to resolve Discord application id");
|
||||
}
|
||||
|
||||
const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : [];
|
||||
const skillCommands = nativeEnabled
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
|
||||
cfg,
|
||||
})
|
||||
: [];
|
||||
const commandSpecs = nativeEnabled ? listNativeCommandSpecsForConfig(cfg, { skillCommands }) : [];
|
||||
const commands = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
|
||||
@@ -98,10 +98,19 @@ function isGoogleModelNotFoundText(text: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGoogleishProvider(provider: string): boolean {
|
||||
return provider === "google" || provider.startsWith("google-");
|
||||
}
|
||||
|
||||
function isRefreshTokenReused(error: string): boolean {
|
||||
return /refresh_token_reused/i.test(error);
|
||||
}
|
||||
|
||||
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||
const msg = raw.toLowerCase();
|
||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||
}
|
||||
|
||||
function isMissingProfileError(error: string): boolean {
|
||||
return /no credentials found for profile/i.test(error);
|
||||
}
|
||||
@@ -471,7 +480,30 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
if (payload?.status !== "ok") {
|
||||
throw new Error(`agent status=${String(payload?.status)}`);
|
||||
}
|
||||
const text = extractPayloadText(payload?.result);
|
||||
let text = extractPayloadText(payload?.result);
|
||||
if (!text) {
|
||||
logProgress(`${progressLabel}: empty response, retrying`);
|
||||
const retry = await client.request<AgentFinalPayload>(
|
||||
"agent",
|
||||
{
|
||||
sessionKey,
|
||||
idempotencyKey: `idem-${randomUUID()}-retry`,
|
||||
message:
|
||||
"Explain in 2-3 sentences how the JavaScript event loop handles microtasks vs macrotasks. Must mention both words: microtask and macrotask.",
|
||||
thinking: params.thinkingLevel,
|
||||
deliver: false,
|
||||
},
|
||||
{ expectFinal: true },
|
||||
);
|
||||
if (retry?.status !== "ok") {
|
||||
throw new Error(`agent status=${String(retry?.status)}`);
|
||||
}
|
||||
text = extractPayloadText(retry?.result);
|
||||
}
|
||||
if (!text && isGoogleishProvider(model.provider)) {
|
||||
logProgress(`${progressLabel}: skip (google empty response)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
isEmptyStreamText(text) &&
|
||||
(model.provider === "minimax" || model.provider === "openai-codex")
|
||||
@@ -479,7 +511,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (${model.provider} empty response)`);
|
||||
break;
|
||||
}
|
||||
if (model.provider === "google" && isGoogleModelNotFoundText(text)) {
|
||||
if (isGoogleishProvider(model.provider) && isGoogleModelNotFoundText(text)) {
|
||||
// Catalog drift: model IDs can disappear or become unavailable on the API.
|
||||
// Treat as skip when scanning "all models" for Google.
|
||||
logProgress(`${progressLabel}: skip (google model not found)`);
|
||||
@@ -491,7 +523,13 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
phase: "prompt",
|
||||
label: params.label,
|
||||
});
|
||||
if (!isMeaningful(text)) throw new Error(`not meaningful: ${text}`);
|
||||
if (!isMeaningful(text)) {
|
||||
if (isGoogleishProvider(model.provider) && /gemini/i.test(model.id)) {
|
||||
logProgress(`${progressLabel}: skip (google not meaningful)`);
|
||||
break;
|
||||
}
|
||||
throw new Error(`not meaningful: ${text}`);
|
||||
}
|
||||
if (!/\bmicro\s*-?\s*tasks?\b/i.test(text) || !/\bmacro\s*-?\s*tasks?\b/i.test(text)) {
|
||||
throw new Error(`missing required keywords: ${text}`);
|
||||
}
|
||||
@@ -735,6 +773,10 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
logProgress(`${progressLabel}: skip (codex refresh token reused)`);
|
||||
break;
|
||||
}
|
||||
if (model.provider === "openai-codex" && isChatGPTUsageLimitErrorMessage(message)) {
|
||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||
break;
|
||||
}
|
||||
if (isMissingProfileError(message)) {
|
||||
skippedCount += 1;
|
||||
logProgress(`${progressLabel}: skip (missing auth profile)`);
|
||||
|
||||
@@ -96,6 +96,8 @@ describe("runGatewayUpdate", () => {
|
||||
await fs.writeFile(path.join(tempDir, "pnpm-lock.yaml"), "", "utf-8");
|
||||
const { runner, calls } = createRunner({
|
||||
[`git -C ${tempDir} rev-parse --show-toplevel`]: { code: 1 },
|
||||
"npm root -g": { code: 1 },
|
||||
"pnpm root -g": { code: 1 },
|
||||
});
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
@@ -106,9 +108,111 @@ describe("runGatewayUpdate", () => {
|
||||
|
||||
expect(result.status).toBe("skipped");
|
||||
expect(result.reason).toBe("not-git-install");
|
||||
expect(calls.some((call) => call.startsWith("pnpm "))).toBe(false);
|
||||
expect(calls.some((call) => call.startsWith("npm "))).toBe(false);
|
||||
expect(calls.some((call) => call.startsWith("bun "))).toBe(false);
|
||||
expect(calls.some((call) => call.startsWith("pnpm add -g"))).toBe(false);
|
||||
expect(calls.some((call) => call.startsWith("npm i -g"))).toBe(false);
|
||||
});
|
||||
|
||||
it("updates global npm installs when detected", async () => {
|
||||
const nodeModules = path.join(tempDir, "node_modules");
|
||||
const pkgRoot = path.join(nodeModules, "clawdbot");
|
||||
await fs.mkdir(pkgRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: nodeModules, stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "npm i -g clawdbot@latest") {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: pkgRoot,
|
||||
runCommand: async (argv, _options) => runCommand(argv),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.mode).toBe("npm");
|
||||
expect(result.before?.version).toBe("1.0.0");
|
||||
expect(result.after?.version).toBe("2.0.0");
|
||||
expect(calls.some((call) => call === "npm i -g clawdbot@latest")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates global bun installs when detected", async () => {
|
||||
const oldBunInstall = process.env.BUN_INSTALL;
|
||||
const bunInstall = path.join(tempDir, "bun-install");
|
||||
process.env.BUN_INSTALL = bunInstall;
|
||||
|
||||
try {
|
||||
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||
const pkgRoot = path.join(bunGlobalRoot, "clawdbot");
|
||||
await fs.mkdir(pkgRoot, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const calls: string[] = [];
|
||||
const runCommand = async (argv: string[]) => {
|
||||
const key = argv.join(" ");
|
||||
calls.push(key);
|
||||
if (key === `git -C ${pkgRoot} rev-parse --show-toplevel`) {
|
||||
return { stdout: "", stderr: "not a git repository", code: 128 };
|
||||
}
|
||||
if (key === "npm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "pnpm root -g") {
|
||||
return { stdout: "", stderr: "", code: 1 };
|
||||
}
|
||||
if (key === "bun add -g clawdbot@latest") {
|
||||
await fs.writeFile(
|
||||
path.join(pkgRoot, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
return { stdout: "ok", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: pkgRoot,
|
||||
runCommand: async (argv, _options) => runCommand(argv),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ok");
|
||||
expect(result.mode).toBe("bun");
|
||||
expect(result.before?.version).toBe("1.0.0");
|
||||
expect(result.after?.version).toBe("2.0.0");
|
||||
expect(calls.some((call) => call === "bun add -g clawdbot@latest")).toBe(true);
|
||||
} finally {
|
||||
if (oldBunInstall === undefined) delete process.env.BUN_INSTALL;
|
||||
else process.env.BUN_INSTALL = oldBunInstall;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects git roots that are not a clawdbot checkout", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os from "node:os";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -158,6 +159,52 @@ async function detectPackageManager(root: string) {
|
||||
return "npm";
|
||||
}
|
||||
|
||||
async function tryRealpath(value: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectGlobalInstallManager(
|
||||
runCommand: CommandRunner,
|
||||
pkgRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<"npm" | "pnpm" | "bun" | null> {
|
||||
const pkgReal = await tryRealpath(pkgRoot);
|
||||
|
||||
const candidates: Array<{
|
||||
manager: "npm" | "pnpm";
|
||||
argv: string[];
|
||||
}> = [
|
||||
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
||||
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
||||
];
|
||||
|
||||
for (const { manager, argv } of candidates) {
|
||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||
if (!res) continue;
|
||||
if (res.code !== 0) continue;
|
||||
const globalRoot = res.stdout.trim();
|
||||
if (!globalRoot) continue;
|
||||
|
||||
const globalReal = await tryRealpath(globalRoot);
|
||||
const expected = path.join(globalReal, "clawdbot");
|
||||
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
||||
}
|
||||
|
||||
// Bun doesn't have an officially stable "global root" command across versions,
|
||||
// so we check the common global install path (best-effort).
|
||||
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type RunStepOptions = {
|
||||
runCommand: CommandRunner;
|
||||
name: string;
|
||||
@@ -220,6 +267,12 @@ function managerInstallArgs(manager: "pnpm" | "bun" | "npm") {
|
||||
return ["npm", "install"];
|
||||
}
|
||||
|
||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun") {
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", "clawdbot@latest"];
|
||||
if (manager === "bun") return ["bun", "add", "-g", "clawdbot@latest"];
|
||||
return ["npm", "i", "-g", "clawdbot@latest"];
|
||||
}
|
||||
|
||||
// Total number of visible steps in a successful git update flow
|
||||
const GIT_UPDATE_TOTAL_STEPS = 9;
|
||||
|
||||
@@ -414,6 +467,32 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
}
|
||||
|
||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
||||
if (globalManager) {
|
||||
const updateStep = await runStep({
|
||||
runCommand,
|
||||
name: "global update",
|
||||
argv: globalUpdateArgs(globalManager),
|
||||
cwd: pkgRoot,
|
||||
timeoutMs,
|
||||
progress,
|
||||
stepIndex: 0,
|
||||
totalSteps: 1,
|
||||
});
|
||||
const steps = [updateStep];
|
||||
const afterVersion = await readPackageVersion(pkgRoot);
|
||||
return {
|
||||
status: updateStep.exitCode === 0 ? "ok" : "error",
|
||||
mode: globalManager,
|
||||
root: pkgRoot,
|
||||
reason: updateStep.exitCode === 0 ? undefined : updateStep.name,
|
||||
before: { version: beforeVersion },
|
||||
after: { version: afterVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "skipped",
|
||||
mode: "unknown",
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function runUi(args: string[], env: NodeJS.ProcessEnv) {
|
||||
return spawnSync(process.execPath, ["scripts/ui.js", ...args], {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
function writeStubPnpm(dir: string) {
|
||||
const isWin = process.platform === "win32";
|
||||
const name = isWin ? "pnpm.cmd" : "pnpm";
|
||||
const target = path.join(dir, name);
|
||||
const contents = isWin ? "@echo off\r\nexit /B 0\r\n" : "#!/bin/sh\nexit 0\n";
|
||||
fs.writeFileSync(target, contents);
|
||||
if (!isWin) fs.chmodSync(target, 0o755);
|
||||
return target;
|
||||
}
|
||||
|
||||
describe("scripts/ui.js", () => {
|
||||
it("fails with a pnpm-only error when pnpm is missing", () => {
|
||||
const result = runUi(["install"], { PATH: "" });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("install pnpm");
|
||||
expect(result.stderr.toLowerCase()).not.toContain("bun");
|
||||
});
|
||||
|
||||
it("runs pnpm when available", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-ui-"));
|
||||
writeStubPnpm(tmp);
|
||||
const result = runUi(["install"], { PATH: tmp });
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -401,4 +401,148 @@ describe("security audit", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("maybeProbeGateway auth selection", () => {
|
||||
it("uses local auth when gateway.mode is local", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: { token: "local-token-abc123" },
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("local-token-abc123");
|
||||
});
|
||||
|
||||
it("uses local auth when gateway.mode is undefined (default)", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
auth: { token: "default-local-token" },
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("default-local-token");
|
||||
});
|
||||
|
||||
it("uses remote auth when gateway.mode is remote with URL", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
auth: { token: "local-token-should-not-use" },
|
||||
remote: {
|
||||
url: "ws://remote.example.com:18789",
|
||||
token: "remote-token-xyz789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("remote-token-xyz789");
|
||||
});
|
||||
|
||||
it("falls back to local auth when gateway.mode is remote but URL is missing", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
auth: { token: "fallback-local-token" },
|
||||
remote: {
|
||||
token: "remote-token-should-not-use",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("fallback-local-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -538,7 +538,7 @@ async function maybeProbeGateway(params: {
|
||||
return { token, password };
|
||||
};
|
||||
|
||||
const auth = remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote");
|
||||
const auth = !isRemoteMode || remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote");
|
||||
const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({
|
||||
ok: false,
|
||||
url,
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
} from "../../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace } from "../../auto-reply/skill-commands.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||
import { resolveNativeCommandsEnabled } from "../../config/commands.js";
|
||||
import { danger, logVerbose } from "../../globals.js";
|
||||
@@ -403,7 +405,15 @@ export function registerSlackMonitorSlashCommands(params: {
|
||||
providerSetting: account.config.commands?.native,
|
||||
globalSetting: cfg.commands?.native,
|
||||
});
|
||||
const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : [];
|
||||
const skillCommands = nativeEnabled
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
|
||||
cfg,
|
||||
})
|
||||
: [];
|
||||
const nativeCommands = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
|
||||
: [];
|
||||
if (nativeCommands.length > 0) {
|
||||
for (const command of nativeCommands) {
|
||||
ctx.app.command(
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { CommandArgs } from "../auto-reply/commands-registry.js";
|
||||
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
@@ -43,10 +45,21 @@ export const registerTelegramNativeCommands = ({
|
||||
shouldSkipUpdate,
|
||||
opts,
|
||||
}) => {
|
||||
const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : [];
|
||||
const skillCommands = nativeEnabled
|
||||
? listSkillCommandsForWorkspace({
|
||||
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
|
||||
cfg,
|
||||
})
|
||||
: [];
|
||||
const nativeCommands = nativeEnabled
|
||||
? listNativeCommandSpecsForConfig(cfg, { skillCommands })
|
||||
: [];
|
||||
const reservedCommands = new Set(
|
||||
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
|
||||
);
|
||||
for (const command of skillCommands) {
|
||||
reservedCommands.add(command.name.toLowerCase());
|
||||
}
|
||||
const customResolution = resolveTelegramCustomCommands({
|
||||
commands: telegramCfg.customCommands,
|
||||
reservedCommands,
|
||||
|
||||
@@ -6,11 +6,20 @@ import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import { listSkillCommandsForWorkspace } from "../auto-reply/skill-commands.js";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||
import { resolveTelegramFetch } from "./fetch.js";
|
||||
|
||||
function resolveSkillCommands(config: Parameters<typeof listNativeCommandSpecsForConfig>[0]) {
|
||||
return listSkillCommandsForWorkspace({
|
||||
workspaceDir: resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config)),
|
||||
cfg: config,
|
||||
});
|
||||
}
|
||||
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
@@ -187,7 +196,8 @@ describe("createTelegramBot", () => {
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const native = listNativeCommandSpecsForConfig(config).map((command) => ({
|
||||
const skillCommands = resolveSkillCommands(config);
|
||||
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
}));
|
||||
@@ -227,7 +237,8 @@ describe("createTelegramBot", () => {
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const native = listNativeCommandSpecsForConfig(config).map((command) => ({
|
||||
const skillCommands = resolveSkillCommands(config);
|
||||
const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
}));
|
||||
|
||||
@@ -258,6 +258,55 @@ describe("web auto-reply", () => {
|
||||
expect(reply).toHaveBeenCalledWith("🦞 hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("defaults responsePrefix for self-chat replies when unset", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
identity: { name: "Mainbot", emoji: "🦞", theme: "space lobster" },
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["+1555"] } },
|
||||
messages: {
|
||||
messagePrefix: undefined,
|
||||
responsePrefix: undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
let capturedOnMessage:
|
||||
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
|
||||
| undefined;
|
||||
const reply = vi.fn();
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
|
||||
}) => {
|
||||
capturedOnMessage = opts.onMessage;
|
||||
return { close: vi.fn() };
|
||||
};
|
||||
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "hello there" });
|
||||
|
||||
await monitorWebChannel(false, listenerFactory, false, resolver);
|
||||
expect(capturedOnMessage).toBeDefined();
|
||||
|
||||
await capturedOnMessage?.({
|
||||
body: "hi",
|
||||
from: "+1555",
|
||||
to: "+1555",
|
||||
selfE164: "+1555",
|
||||
chatType: "direct",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply,
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
|
||||
expect(reply).toHaveBeenCalledWith("[Mainbot] hello there");
|
||||
resetLoadConfigMock();
|
||||
});
|
||||
it("does not deliver HEARTBEAT_OK responses", async () => {
|
||||
setLoadConfigMock(() => ({
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../../../agents/identity.js";
|
||||
import {
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveIdentityName,
|
||||
resolveIdentityNamePrefix,
|
||||
} from "../../../agents/identity.js";
|
||||
import {
|
||||
extractShortModelName,
|
||||
type ResponsePrefixContext,
|
||||
@@ -172,10 +176,17 @@ export async function processMessage(params: {
|
||||
const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp");
|
||||
let didLogHeartbeatStrip = false;
|
||||
let didSendReply = false;
|
||||
const responsePrefix = resolveEffectiveMessagesConfig(
|
||||
params.cfg,
|
||||
params.route.agentId,
|
||||
).responsePrefix;
|
||||
const configuredResponsePrefix = params.cfg.messages?.responsePrefix;
|
||||
const resolvedMessages = resolveEffectiveMessagesConfig(params.cfg, params.route.agentId);
|
||||
const isSelfChat =
|
||||
params.msg.chatType !== "group" &&
|
||||
Boolean(params.msg.selfE164) &&
|
||||
normalizeE164(params.msg.from) === normalizeE164(params.msg.selfE164 ?? "");
|
||||
const responsePrefix =
|
||||
resolvedMessages.responsePrefix ??
|
||||
(configuredResponsePrefix === undefined && isSelfChat
|
||||
? (resolveIdentityNamePrefix(params.cfg, params.route.agentId) ?? "[clawdbot]")
|
||||
: undefined);
|
||||
|
||||
// Create mutable context for response prefix template interpolation
|
||||
let prefixContext: ResponsePrefixContext = {
|
||||
|
||||
Reference in new issue
Block a user