diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml
index b610e1718..6d9f55903 100644
--- a/.github/workflows/auto-response.yml
+++ b/.github/workflows/auto-response.yml
@@ -24,13 +24,26 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
+ // Labels prefixed with "r:" are auto-response triggers.
const rules = [
{
- label: "skill-clawdhub",
+ label: "r: skill",
close: true,
message:
"Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
},
+ {
+ label: "r: support",
+ close: true,
+ message:
+ "Please use our support server https://molt.bot/discord and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.molt.bot/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
+ },
+ {
+ label: "r: third-party-extension",
+ close: true,
+ message:
+ "This would be better made as a third-party extension with our SDK that you maintain yourself. Docs: https://docs.molt.bot/plugin.",
+ },
];
const labelName = context.payload.label?.name;
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac94054e3..5909c9899 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,8 +2,8 @@
Docs: https://docs.molt.bot
-## 2026.1.26
-Status: unreleased.
+## 2026.1.27-beta.1
+Status: beta.
### Changes
- Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope.
@@ -22,6 +22,7 @@ Status: unreleased.
- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos.
- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248)
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
+- Config: auto-migrate legacy state/config paths and keep config resolution consistent across legacy filenames.
- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
@@ -50,6 +51,7 @@ Status: unreleased.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
+- Docs: update exe.dev install instructions. (#https://github.com/moltbot/moltbot/pull/3047) Thanks @zackerthescar.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
@@ -63,15 +65,29 @@ Status: unreleased.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
+- CLI: use Node's module compile cache for faster startup. (#2808) Thanks @pi0.
+- Routing: add per-account DM session scope and document multi-account isolation. (#3095) Thanks @jarvis-sam.
### Breaking
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- Discord: restore username directory lookup in target resolution. (#3131) Thanks @bonald.
+- Agents: align MiniMax base URL test expectation with default provider config. (#3131) Thanks @bonald.
+- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma.
- Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94.
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
+- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
+- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
+- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
+- Discord: avoid resolving bare channel names to user DMs when a username matches. Thanks @thewilloftheshadow.
+- Discord: fix directory config type import for target resolution. Thanks @thewilloftheshadow.
+- Providers: update MiniMax API endpoint and compatibility mode. (#3064) Thanks @hlbbbbbbb.
+- Telegram: treat more network errors as recoverable in polling. (#3013) Thanks @ryancontent.
+- Discord: resolve usernames to user IDs for outbound messages. (#2649) Thanks @nonggialiang.
+- Providers: update Moonshot Kimi model references to kimi-k2.5. (#2762) Thanks @MarvinCui.
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
@@ -85,6 +101,7 @@ Status: unreleased.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
+- Telegram: ignore non-forum group message_thread_id while preserving DM thread sessions. (#2731) Thanks @dylanneve1.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 6d3fa6045..3ddcb3b81 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -22,7 +22,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202601260
- versionName = "2026.1.26"
+ versionName = "2026.1.27-beta.1"
}
buildTypes {
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 37e0bad49..d3e398ab4 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.26
+ 2026.1.27-beta.1
CFBundleVersion
20260126
NSAppTransportSecurity
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index f3eb12b09..a5336c6ad 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.1.26
+ 2026.1.27-beta.1
CFBundleVersion
20260126
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index a7305c26c..a6728cd98 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -81,7 +81,7 @@ targets:
properties:
CFBundleDisplayName: Moltbot
CFBundleIconName: AppIcon
- CFBundleShortVersionString: "2026.1.26"
+ CFBundleShortVersionString: "2026.1.27-beta.1"
CFBundleVersion: "20260126"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: MoltbotTests
- CFBundleShortVersionString: "2026.1.26"
+ CFBundleShortVersionString: "2026.1.27-beta.1"
CFBundleVersion: "20260126"
diff --git a/apps/macos/Sources/Moltbot/Resources/Info.plist b/apps/macos/Sources/Moltbot/Resources/Info.plist
index 89c5a2d9e..0c0de8b9e 100644
--- a/apps/macos/Sources/Moltbot/Resources/Info.plist
+++ b/apps/macos/Sources/Moltbot/Resources/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.26
+ 2026.1.27-beta.1
CFBundleVersion
202601260
CFBundleIconFile
diff --git a/docs/cli/acp.md b/docs/cli/acp.md
index da2de00b3..a7cb0e1d6 100644
--- a/docs/cli/acp.md
+++ b/docs/cli/acp.md
@@ -42,7 +42,7 @@ moltbot acp client
moltbot acp client --server-args --url wss://gateway-host:18789 --token
# Override the server command (default: moltbot)
-moltbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
+moltbot acp client --server "node" --server-args moltbot.mjs acp --url ws://127.0.0.1:19001
```
## How to use this
diff --git a/docs/cli/security.md b/docs/cli/security.md
index 662181616..551debc99 100644
--- a/docs/cli/security.md
+++ b/docs/cli/security.md
@@ -20,5 +20,5 @@ moltbot security audit --deep
moltbot security audit --fix
```
-The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` for shared inboxes.
+The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md
index ef27fc9e3..9dbb984fc 100644
--- a/docs/concepts/model-providers.md
+++ b/docs/concepts/model-providers.md
@@ -130,9 +130,10 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
- Provider: `moonshot`
- Auth: `MOONSHOT_API_KEY`
-- Example model: `moonshot/kimi-k2-0905-preview`
+- Example model: `moonshot/kimi-k2.5`
- Kimi K2 model IDs:
{/* moonshot-kimi-k2-model-refs:start */}
+ - `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
@@ -141,7 +142,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
```json5
{
agents: {
- defaults: { model: { primary: "moonshot/kimi-k2-0905-preview" } }
+ defaults: { model: { primary: "moonshot/kimi-k2.5" } }
},
models: {
mode: "merge",
@@ -150,7 +151,7 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
baseUrl: "https://api.moonshot.ai/v1",
apiKey: "${MOONSHOT_API_KEY}",
api: "openai-completions",
- models: [{ id: "kimi-k2-0905-preview", name: "Kimi K2 0905 Preview" }]
+ models: [{ id: "kimi-k2.5", name: "Kimi K2.5" }]
}
}
}
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 58ac57145..b15b1a1ea 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -11,7 +11,8 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `main` (default): all DMs share the main session for continuity.
- `per-peer`: isolate by sender id across channels.
- `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
-Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
+- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
+Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” Moltbot). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.
@@ -44,6 +45,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
- `per-peer`: `agent::dm:`.
- `per-channel-peer`: `agent:::dm:`.
+ - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`).
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels.
- Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`).
- Telegram forum topics append `:topic:` to the group id for isolation.
@@ -94,7 +96,7 @@ Send these as standalone messages so they register.
{
session: {
scope: "per-sender", // keep group keys separate
- dmScope: "main", // DM continuity (set per-channel-peer for shared inboxes)
+ dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes)
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
diff --git a/docs/debug/node-issue.md b/docs/debug/node-issue.md
index c71b903f3..a549ad51b 100644
--- a/docs/debug/node-issue.md
+++ b/docs/debug/node-issue.md
@@ -55,9 +55,9 @@ node --import tsx scripts/repro/tsx-name-repro.ts
- Use Node + tsc watch, then run compiled output:
```bash
pnpm exec tsc --watch --preserveWatchOutput
- node --watch dist/entry.js status
+ node --watch moltbot.mjs status
```
-- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
+- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node moltbot.mjs status` works on Node 25.
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index f5438fb46..1d270974d 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -2396,8 +2396,8 @@ Use Moonshot's OpenAI-compatible endpoint:
env: { MOONSHOT_API_KEY: "sk-..." },
agents: {
defaults: {
- model: { primary: "moonshot/kimi-k2-0905-preview" },
- models: { "moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" } }
+ model: { primary: "moonshot/kimi-k2.5" },
+ models: { "moonshot/kimi-k2.5": { alias: "Kimi K2.5" } }
}
},
models: {
@@ -2409,8 +2409,8 @@ Use Moonshot's OpenAI-compatible endpoint:
api: "openai-completions",
models: [
{
- id: "kimi-k2-0905-preview",
- name: "Kimi K2 0905 Preview",
+ id: "kimi-k2.5",
+ name: "Kimi K2.5",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -2426,7 +2426,7 @@ Use Moonshot's OpenAI-compatible endpoint:
Notes:
- Set `MOONSHOT_API_KEY` in the environment or use `moltbot onboard --auth-choice moonshot-api-key`.
-- Model ref: `moonshot/kimi-k2-0905-preview`.
+- Model ref: `moonshot/kimi-k2.5`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
@@ -2657,7 +2657,8 @@ Fields:
- `main`: all DMs share the main session for continuity.
- `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
-- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
+ - `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
+- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
diff --git a/docs/gateway/security/formal-verification.md b/docs/gateway/security/formal-verification.md
index 3d41aed06..f5c6bbbb4 100644
--- a/docs/gateway/security/formal-verification.md
+++ b/docs/gateway/security/formal-verification.md
@@ -1,13 +1,15 @@
---
title: Formal Verification (Security Models)
summary: Machine-checked security models for Moltbot’s highest-risk paths.
-permalink: /gateway/security/formal-verification/
+permalink: /security/formal-verification/
---
# Formal Verification (Security Models)
This page tracks Moltbot’s **formal security models** (TLA+/TLC today; more as needed).
+> Note: some older links may refer to the previous project name.
+
**Goal (north star):** provide a machine-checked argument that Moltbot enforces its
intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions.
@@ -20,7 +22,7 @@ misconfiguration safety), under explicit assumptions.
## Where the models live
-Models are maintained in a separate repo: [vignesh07/moltbot-formal-models](https://github.com/vignesh07/moltbot-formal-models).
+Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
@@ -37,8 +39,8 @@ Today, results are reproduced by cloning the models repo locally and running TLC
Getting started:
```bash
-git clone https://github.com/vignesh07/moltbot-formal-models
-cd moltbot-formal-models
+git clone https://github.com/vignesh07/clawdbot-formal-models
+cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
@@ -98,10 +100,61 @@ See also: `docs/gateway-exposure-matrix.md` in the models repo.
- Red (expected):
- `make routing-isolation-negative`
-## Roadmap
-Next models to deepen fidelity:
-- Pairing store concurrency/locking/idempotency
-- Provider-specific ingress preflight modeling
-- Routing identity-links + dmScope variants + binding precedence
-- Gateway auth conformance (proxy/tailscale specifics)
+## v1++: additional bounded models (concurrency, retries, trace correctness)
+
+These are follow-on models that tighten fidelity around real-world failure modes (non-atomic updates, retries, and message fan-out).
+
+### Pairing store concurrency / idempotency
+
+**Claim:** a pairing store should enforce `MaxPending` and idempotency even under interleavings (i.e., “check-then-write” must be atomic / locked; refresh shouldn’t create duplicates).
+
+What it means:
+- Under concurrent requests, you can’t exceed `MaxPending` for a channel.
+- Repeated requests/refreshes for the same `(channel, sender)` should not create duplicate live pending rows.
+
+- Green runs:
+ - `make pairing-race` (atomic/locked cap check)
+ - `make pairing-idempotency`
+ - `make pairing-refresh`
+ - `make pairing-refresh-race`
+- Red (expected):
+ - `make pairing-race-negative` (non-atomic begin/commit cap race)
+ - `make pairing-idempotency-negative`
+ - `make pairing-refresh-negative`
+ - `make pairing-refresh-race-negative`
+
+### Ingress trace correlation / idempotency
+
+**Claim:** ingestion should preserve trace correlation across fan-out and be idempotent under provider retries.
+
+What it means:
+- When one external event becomes multiple internal messages, every part keeps the same trace/event identity.
+- Retries do not result in double-processing.
+- If provider event IDs are missing, dedupe falls back to a safe key (e.g., trace ID) to avoid dropping distinct events.
+
+- Green:
+ - `make ingress-trace`
+ - `make ingress-trace2`
+ - `make ingress-idempotency`
+ - `make ingress-dedupe-fallback`
+- Red (expected):
+ - `make ingress-trace-negative`
+ - `make ingress-trace2-negative`
+ - `make ingress-idempotency-negative`
+ - `make ingress-dedupe-fallback-negative`
+
+### Routing dmScope precedence + identityLinks
+
+**Claim:** routing must keep DM sessions isolated by default, and only collapse sessions when explicitly configured (channel precedence + identity links).
+
+What it means:
+- Channel-specific dmScope overrides must win over global defaults.
+- identityLinks should collapse only within explicit linked groups, not across unrelated peers.
+
+- Green:
+ - `make routing-precedence`
+ - `make routing-identitylinks`
+- Red (expected):
+ - `make routing-precedence-negative`
+ - `make routing-identitylinks-negative`
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index e3c85af7f..a5d841c18 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -5,7 +5,7 @@ read_when:
---
# Security 🔒
-## Quick check: `moltbot security audit`
+## Quick check: `moltbot security audit` (formerly `clawdbot security audit`)
See also: [Formal Verification (Security Models)](/security/formal-verification/)
@@ -15,6 +15,8 @@ Run this regularly (especially after changing config or exposing network surface
moltbot security audit
moltbot security audit --deep
moltbot security audit --fix
+
+# (On older installs, the command is `clawdbot ...`.)
```
It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions).
@@ -22,7 +24,7 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
`--fix` applies safe guardrails:
- Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels.
- Turn `logging.redactSensitive="off"` back to `"tools"`.
-- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
+- Tighten local perms (`~/.moltbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
@@ -49,13 +51,13 @@ If you run `--deep`, Moltbot also attempts a best-effort live Gateway probe.
Use this when auditing access or deciding what to back up:
-- **WhatsApp**: `~/.clawdbot/credentials/whatsapp//creds.json`
+- **WhatsApp**: `~/.moltbot/credentials/whatsapp//creds.json`
- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
- **Discord bot token**: config/env (token file not yet supported)
- **Slack tokens**: config/env (`channels.slack.*`)
-- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json`
-- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json`
-- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
+- **Pairing allowlists**: `~/.moltbot/credentials/-allowFrom.json`
+- **Model auth profiles**: `~/.moltbot/agents//agent/auth-profiles.json`
+- **Legacy OAuth import**: `~/.moltbot/credentials/oauth.json`
## Security Audit Checklist
@@ -100,10 +102,10 @@ When `trustedProxies` is configured, the Gateway will use `X-Forwarded-For` head
## Local session logs live on disk
-Moltbot stores session transcripts on disk under `~/.clawdbot/agents//sessions/*.jsonl`.
+Moltbot stores session transcripts on disk under `~/.moltbot/agents//sessions/*.jsonl`.
This is required for session continuity and (optionally) session memory indexing, but it also means
**any process/user with filesystem access can read those logs**. Treat disk access as the trust
-boundary and lock down permissions on `~/.clawdbot` (see the audit section below). If you need
+boundary and lock down permissions on `~/.moltbot` (see the audit section below). If you need
stronger isolation between agents, run them under separate OS users or separate hosts.
## Node execution (system.run)
@@ -163,7 +165,7 @@ Plugins run **in-process** with the Gateway. Treat them as trusted code:
- Review plugin config before enabling.
- Restart the Gateway after plugin changes.
- If you install plugins from npm (`moltbot plugins install `), treat it like running untrusted code:
- - The install path is `~/.clawdbot/extensions//` (or `$CLAWDBOT_STATE_DIR/extensions//`).
+ - The install path is `~/.moltbot/extensions//` (or `$CLAWDBOT_STATE_DIR/extensions//`).
- Moltbot uses `npm pack` and then runs `npm install --omit=dev` in that directory (npm lifecycle scripts can execute code during install).
- Prefer pinned, exact versions (`@scope/pkg@1.2.3`), and inspect the unpacked code on disk before enabling.
@@ -197,14 +199,14 @@ By default, Moltbot routes **all DMs into the main session** so your assistant h
}
```
-This prevents cross-user context leakage while keeping group chats isolated. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
+This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology
Moltbot has two separate “who can trigger me?” layers:
- **DM allowlist** (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`): who is allowed to talk to the bot in direct messages.
- - When `dmPolicy="pairing"`, approvals are written to `~/.clawdbot/credentials/-allowFrom.json` (merged with config allowlists).
+ - When `dmPolicy="pairing"`, approvals are written to `~/.moltbot/credentials/-allowFrom.json` (merged with config allowlists).
- **Group allowlist** (channel-specific): which groups/channels/guilds the bot will accept messages from at all.
- Common patterns:
- `channels.whatsapp.groups`, `channels.telegram.groups`, `channels.imessage.groups`: per-group defaults like `requireMention`; when set, it also acts as a group allowlist (include `"*"` to keep allow-all behavior).
@@ -231,7 +233,7 @@ Red flags to treat as untrusted:
- “Read this file/URL and do exactly what it says.”
- “Ignore your system prompt or safety rules.”
- “Reveal your hidden instructions or tool outputs.”
-- “Paste the full contents of ~/.clawdbot or your logs.”
+- “Paste the full contents of ~/.moltbot or your logs.”
### Prompt injection does not require public DMs
@@ -308,8 +310,8 @@ This is social engineering 101. Create distrust, encourage snooping.
### 0) File permissions
Keep config + state private on the gateway host:
-- `~/.clawdbot/moltbot.json`: `600` (user read/write only)
-- `~/.clawdbot`: `700` (user only)
+- `~/.moltbot/moltbot.json`: `600` (user read/write only)
+- `~/.moltbot`: `700` (user only)
`moltbot doctor` can warn and offer to tighten these permissions.
@@ -448,7 +450,7 @@ Avoid:
### 0.7) Secrets on disk (what’s sensitive)
-Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
+Assume anything under `~/.moltbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
- `moltbot.json`: config may include tokens (gateway, remote gateway), provider settings, and allowlists.
- `credentials/**`: channel credentials (example: WhatsApp creds), pairing allowlists, legacy OAuth imports.
@@ -572,9 +574,6 @@ If that browser profile already contains logged-in sessions, the model can
access those accounts and data. Treat browser profiles as **sensitive state**:
- Prefer a dedicated profile for the agent (the default `clawd` profile).
- Avoid pointing the agent at your personal daily-driver profile.
-- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
- Prompt injection can steer the model into calling them. If you do not need
- them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Keep host browser control disabled for sandboxed agents unless you trust them.
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
@@ -691,7 +690,7 @@ If your AI does something bad:
### Audit
1. Check Gateway logs: `/tmp/moltbot/moltbot-YYYY-MM-DD.log` (or `logging.file`).
-2. Review the relevant transcript(s): `~/.clawdbot/agents//sessions/*.jsonl`.
+2. Review the relevant transcript(s): `~/.moltbot/agents//sessions/*.jsonl`.
3. Review recent config changes (anything that could have widened access: `gateway.bind`, `gateway.auth`, dm/group policies, `tools.elevated`, plugin changes).
### Collect for a report
@@ -750,7 +749,7 @@ Mario asking for find ~
Found a vulnerability in Moltbot? Please report responsibly:
-1. Email: security@molt.bot
+1. Email: security@clawd.bot
2. Don't post publicly until fixed
3. We'll credit you (unless you prefer anonymity)
diff --git a/docs/help/faq.md b/docs/help/faq.md
index 1b4f3b7ba..7372a4997 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -1026,7 +1026,7 @@ Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-v
**Can I run Apple macOS only skills from Linux**
-Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
+Not directly. macOS skills are gated by `metadata.moltbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
You have three supported patterns:
diff --git a/docs/hooks.md b/docs/hooks.md
index fddab384c..8576146ba 100644
--- a/docs/hooks.md
+++ b/docs/hooks.md
@@ -149,7 +149,7 @@ No configuration needed.
### Metadata Fields
-The `metadata.clawdbot` object supports:
+The `metadata.moltbot` object supports:
- **`emoji`**: Display emoji for CLI (e.g., `"💾"`)
- **`events`**: Array of events to listen for (e.g., `["command:new", "command:reset"]`)
diff --git a/docs/install/updating.md b/docs/install/updating.md
index 634abfe99..12303cb2a 100644
--- a/docs/install/updating.md
+++ b/docs/install/updating.md
@@ -125,7 +125,7 @@ moltbot health
```
Notes:
-- `pnpm build` matters when you run the packaged `moltbot` binary ([`dist/entry.js`](https://github.com/moltbot/moltbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
+- `pnpm build` matters when you run the packaged `moltbot` binary ([`moltbot.mjs`](https://github.com/moltbot/moltbot/blob/main/moltbot.mjs)) or use Node to run `dist/`.
- If you run from a repo checkout without a global install, use `pnpm moltbot ...` for CLI commands.
- If you run directly from TypeScript (`pnpm moltbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.
- Switching between global and git installs is easy: install the other flavor, then run `moltbot doctor` so the gateway service entrypoint is rewritten to the current install.
diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md
index 2e58d5dcd..796ddc374 100644
--- a/docs/platforms/exe-dev.md
+++ b/docs/platforms/exe-dev.md
@@ -7,40 +7,47 @@ read_when:
# exe.dev
-Goal: Moltbot Gateway running on an exe.dev VM, reachable from your laptop via:
-- **exe.dev HTTPS proxy** (easy, no tunnel) or
-- **SSH tunnel** (most secure; loopback-only Gateway)
+Goal: Moltbot Gateway running on an exe.dev VM, reachable from your laptop via: `https://.exe.xyz`
-This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly.
-
-If you’re on any other Linux VPS, the same steps apply — you just won’t use the exe.dev proxy commands.
+This page assumes exe.dev's default **exeuntu** image. If you picked a different distro, map packages accordingly.
## Beginner quick path
-1) Create VM → install Node 22 → install Moltbot
-2) Run `moltbot onboard --install-daemon`
-3) Tunnel from laptop (`ssh -N -L 18789:127.0.0.1:18789 …`)
-4) Open `http://127.0.0.1:18789/` and paste your token
+1) [https://exe.new/moltbot](https://exe.new/moltbot)
+2) Fill in your auth key/token as needed
+3) Click on "Agent" next to your VM, and wait...
+4) ???
+5) Profit
## What you need
-- exe.dev account + `ssh exe.dev` working on your laptop
-- SSH keys set up (your laptop → exe.dev)
-- Model auth (OAuth or API key) you want to use
-- Provider credentials (optional): WhatsApp QR scan, Telegram bot token, Discord bot token, …
+- exe.dev account
+- `ssh exe.dev` access to [exe.dev](https://exe.dev) virtual machines (optional)
+
+
+## Automated Install with Shelley
+
+Shelley, [exe.dev](https://exe.dev)'s agent, can install Moltbot instantly with our
+prompt. The prompt used is as below:
+
+```
+Set up Moltbot (https://docs.molt.bot/install) on this VM. Use the non-interactive and accept-risk flags for moltbot onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "moltbot devices list" and "moltbot device approve ". Make sure the dashboard shows that Moltbot's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be .exe.xyz, without port specification.
+```
+
+## Manual installation
## 1) Create the VM
-From your laptop:
+From your device:
```bash
-ssh exe.dev new --name=moltbot
+ssh exe.dev new
```
Then connect:
```bash
-ssh moltbot.exe.xyz
+ssh .exe.xyz
```
Tip: keep this VM **stateful**. Moltbot stores state under `~/.clawdbot/` and `~/clawd/`.
@@ -52,130 +59,61 @@ sudo apt-get update
sudo apt-get install -y git curl jq ca-certificates openssl
```
-### Node 22
-
-Install Node **>= 22.12** (any method is fine). Quick check:
-
-```bash
-node -v
-```
-
-If you don’t already have Node 22 on the VM, use your preferred Node manager (nvm/mise/asdf) or a distro package source that provides Node 22+.
-
-Common Ubuntu/Debian option (NodeSource):
-
-```bash
-curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
-sudo apt-get install -y nodejs
-```
-
## 3) Install Moltbot
-Recommended on servers: npm global install.
+Run the Moltbot install script:
```bash
-npm i -g moltbot@latest
-moltbot --version
+curl -fsSL https://molt.bot/install.sh | bash
```
-If native deps fail to install (rare; usually `sharp`), add build tools:
+## 4) Setup nginx to proxy Moltbot to port 8000
+
+Edit `/etc/nginx/sites-enabled/default` with
-```bash
-sudo apt-get install -y build-essential python3
```
+server {
+ listen 80 default_server;
+ listen [::]:80 default_server;
+ listen 8000;
+ listen [::]:8000;
-## 4) First-time setup (wizard)
+ server_name _;
-Run the onboarding wizard on the VM:
+ location / {
+ proxy_pass http://127.0.0.1:18789;
+ proxy_http_version 1.1;
-```bash
-moltbot onboard --install-daemon
-```
+ # WebSocket support
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
-It can set up:
-- `~/clawd` workspace bootstrap
-- `~/.clawdbot/moltbot.json` config
-- model auth profiles
-- model provider config/login
-- Linux systemd **user** service (service)
+ # Standard proxy headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
-If you’re doing OAuth on a headless VM: do OAuth on a normal machine first, then copy the auth profile to the VM (see [Help](/help)).
-
-## 5) Remote access options
-
-### Option A (recommended): SSH tunnel (loopback-only)
-
-Keep Gateway on loopback (default) and tunnel it from your laptop:
-
-```bash
-ssh -N -L 18789:127.0.0.1:18789 moltbot.exe.xyz
-```
-
-Open locally:
-- `http://127.0.0.1:18789/` (Control UI)
-
-Runbook: [Remote access](/gateway/remote)
-
-### Option B: exe.dev HTTPS proxy (no tunnel)
-
-To let exe.dev proxy traffic to the VM, bind the Gateway to the LAN interface and set a token:
-
-```bash
-export CLAWDBOT_GATEWAY_TOKEN="$(openssl rand -hex 32)"
-moltbot gateway --bind lan --port 8080 --token "$CLAWDBOT_GATEWAY_TOKEN"
-```
-
-For service runs, persist it in `~/.clawdbot/moltbot.json`:
-
-```json5
-{
- gateway: {
- mode: "local",
- port: 8080,
- bind: "lan",
- auth: { mode: "token", token: "YOUR_TOKEN" }
- }
+ # Timeout settings for long-lived connections
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ }
}
```
-Notes:
-- Non-loopback binds require `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
-- `gateway.remote.token` is only for remote CLI calls; it does not enable local auth.
+## 5) Access Moltbot and grant privileges
-Then point exe.dev’s proxy at `8080` (or whatever port you chose) and open your VM’s HTTPS URL:
+Access `https://.exe.xyz/?token=YOUR-TOKEN-FROM-TERMINAL`. Approve
+devices with `moltbot devices list` and `moltbot device approve`. When in doubt,
+use Shelley from your browser!
-```bash
-ssh exe.dev share port moltbot 8080
-```
+## Remote Access
-Open:
-- `https://moltbot.exe.xyz/`
+Remote access is handled by [exe.dev](https://exe.dev)'s authentication. By
+default, HTTP traffic from port 8000 is forwarded to `https://.exe.xyz`
+with email auth.
-In the Control UI, paste the token (UI → Settings → token). The UI sends it as `connect.params.auth.token`.
-
-Notes:
-- Prefer a **non-default** port (like `8080`) if your proxy expects an app port.
-- Treat the token like a password.
-
-Control UI details: [Control UI](/web/control-ui)
-
-## 6) Keep it running (service)
-
-On Linux, Moltbot uses a systemd **user** service. After `--install-daemon`, verify:
-
-```bash
-systemctl --user status moltbot-gateway[-].service
-```
-
-If the service dies after logout, enable lingering:
-
-```bash
-sudo loginctl enable-linger "$USER"
-```
-
-More: [Linux](/platforms/linux)
-
-## 7) Updates
+## Updating
```bash
npm i -g moltbot@latest
diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md
index 545c4fe82..d8db124ac 100644
--- a/docs/platforms/fly.md
+++ b/docs/platforms/fly.md
@@ -185,7 +185,7 @@ cat > /data/moltbot.json << 'EOF'
"bind": "auto"
},
"meta": {
- "lastTouchedVersion": "2026.1.26"
+ "lastTouchedVersion": "2026.1.27-beta.1"
}
}
EOF
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index 4be82c67a..237eac616 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
-APP_VERSION=2026.1.26 \
+APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
-ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.26.zip
+ditto -c -k --sequesterRsrc --keepParent dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg
+scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.27-beta.1.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Moltbot.app dist/Moltbot-2026.1.26.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=moltbot-notary \
BUNDLE_ID=bot.molt.mac \
-APP_VERSION=2026.1.26 \
+APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
-ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.26.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/Moltbot.app.dSYM dist/Moltbot-2026.1.27-beta.1.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
-SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.26.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml
+SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Moltbot-2026.1.27-beta.1.zip https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/moltbot/moltbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
-- Upload `Moltbot-2026.1.26.zip` (and `Moltbot-2026.1.26.dSYM.zip`) to the GitHub release for tag `v2026.1.26`.
+- Upload `Moltbot-2026.1.27-beta.1.zip` (and `Moltbot-2026.1.27-beta.1.dSYM.zip`) to the GitHub release for tag `v2026.1.27-beta.1`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/moltbot/moltbot/main/appcast.xml` returns 200.
diff --git a/docs/platforms/mac/skills.md b/docs/platforms/mac/skills.md
index 5f80b9d5e..aad035d53 100644
--- a/docs/platforms/mac/skills.md
+++ b/docs/platforms/mac/skills.md
@@ -11,10 +11,10 @@ The macOS app surfaces Moltbot skills via the gateway; it does not parse skills
## Data source
- `skills.status` (gateway) returns all skills plus eligibility and missing requirements
(including allowlist blocks for bundled skills).
-- Requirements are derived from `metadata.clawdbot.requires` in each `SKILL.md`.
+- Requirements are derived from `metadata.moltbot.requires` in each `SKILL.md`.
## Install actions
-- `metadata.clawdbot.install` defines install options (brew/node/go/uv).
+- `metadata.moltbot.install` defines install options (brew/node/go/uv).
- The app calls `skills.install` to run installers on the gateway host.
- The gateway surfaces only one preferred installer when multiple are provided
(brew when available, otherwise node manager from `skills.install`, default npm).
diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md
index 7e0723f7e..a1f2d18ad 100644
--- a/docs/providers/moonshot.md
+++ b/docs/providers/moonshot.md
@@ -9,11 +9,12 @@ read_when:
# Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
-provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use
+provider and set the default model to `moonshot/kimi-k2.5`, or use
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */}
+- `kimi-k2.5`
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
@@ -39,9 +40,10 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
env: { MOONSHOT_API_KEY: "sk-..." },
agents: {
defaults: {
- model: { primary: "moonshot/kimi-k2-0905-preview" },
+ model: { primary: "moonshot/kimi-k2.5" },
models: {
// moonshot-kimi-k2-aliases:start
+ "moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
"moonshot/kimi-k2-0905-preview": { alias: "Kimi K2" },
"moonshot/kimi-k2-turbo-preview": { alias: "Kimi K2 Turbo" },
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
@@ -59,6 +61,15 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
api: "openai-completions",
models: [
// moonshot-kimi-k2-models:start
+ {
+ id: "kimi-k2.5",
+ name: "Kimi K2.5",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 256000,
+ maxTokens: 8192
+ },
{
id: "kimi-k2-0905-preview",
name: "Kimi K2 0905 Preview",
diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md
index 68d1c0223..e648fb33c 100644
--- a/docs/reference/RELEASING.md
+++ b/docs/reference/RELEASING.md
@@ -17,10 +17,10 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
-- [ ] Bump `package.json` version (e.g., `2026.1.26`).
+- [ ] Bump `package.json` version (e.g., `2026.1.27-beta.1`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/moltbot/moltbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/moltbot/moltbot/blob/main/src/provider-web.ts).
-- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/moltbot/moltbot/blob/main/dist/entry.js) for `moltbot`.
+- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`moltbot.mjs`](https://github.com/moltbot/moltbot/blob/main/moltbot.mjs) for `moltbot`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
2) **Build & artifacts**
diff --git a/docs/security/formal-verification.md b/docs/security/formal-verification.md
index 437fc11a6..f5c6bbbb4 100644
--- a/docs/security/formal-verification.md
+++ b/docs/security/formal-verification.md
@@ -8,6 +8,8 @@ permalink: /security/formal-verification/
This page tracks Moltbot’s **formal security models** (TLA+/TLC today; more as needed).
+> Note: some older links may refer to the previous project name.
+
**Goal (north star):** provide a machine-checked argument that Moltbot enforces its
intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions.
@@ -20,7 +22,7 @@ misconfiguration safety), under explicit assumptions.
## Where the models live
-Models are maintained in a separate repo: [vignesh07/moltbot-formal-models](https://github.com/vignesh07/moltbot-formal-models).
+Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
@@ -37,8 +39,8 @@ Today, results are reproduced by cloning the models repo locally and running TLC
Getting started:
```bash
-git clone https://github.com/vignesh07/moltbot-formal-models
-cd moltbot-formal-models
+git clone https://github.com/vignesh07/clawdbot-formal-models
+cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
@@ -98,10 +100,61 @@ See also: `docs/gateway-exposure-matrix.md` in the models repo.
- Red (expected):
- `make routing-isolation-negative`
-## Roadmap
-Next models to deepen fidelity:
-- Pairing store concurrency/locking/idempotency
-- Provider-specific ingress preflight modeling
-- Routing identity-links + dmScope variants + binding precedence
-- Gateway auth conformance (proxy/tailscale specifics)
+## v1++: additional bounded models (concurrency, retries, trace correctness)
+
+These are follow-on models that tighten fidelity around real-world failure modes (non-atomic updates, retries, and message fan-out).
+
+### Pairing store concurrency / idempotency
+
+**Claim:** a pairing store should enforce `MaxPending` and idempotency even under interleavings (i.e., “check-then-write” must be atomic / locked; refresh shouldn’t create duplicates).
+
+What it means:
+- Under concurrent requests, you can’t exceed `MaxPending` for a channel.
+- Repeated requests/refreshes for the same `(channel, sender)` should not create duplicate live pending rows.
+
+- Green runs:
+ - `make pairing-race` (atomic/locked cap check)
+ - `make pairing-idempotency`
+ - `make pairing-refresh`
+ - `make pairing-refresh-race`
+- Red (expected):
+ - `make pairing-race-negative` (non-atomic begin/commit cap race)
+ - `make pairing-idempotency-negative`
+ - `make pairing-refresh-negative`
+ - `make pairing-refresh-race-negative`
+
+### Ingress trace correlation / idempotency
+
+**Claim:** ingestion should preserve trace correlation across fan-out and be idempotent under provider retries.
+
+What it means:
+- When one external event becomes multiple internal messages, every part keeps the same trace/event identity.
+- Retries do not result in double-processing.
+- If provider event IDs are missing, dedupe falls back to a safe key (e.g., trace ID) to avoid dropping distinct events.
+
+- Green:
+ - `make ingress-trace`
+ - `make ingress-trace2`
+ - `make ingress-idempotency`
+ - `make ingress-dedupe-fallback`
+- Red (expected):
+ - `make ingress-trace-negative`
+ - `make ingress-trace2-negative`
+ - `make ingress-idempotency-negative`
+ - `make ingress-dedupe-fallback-negative`
+
+### Routing dmScope precedence + identityLinks
+
+**Claim:** routing must keep DM sessions isolated by default, and only collapse sessions when explicitly configured (channel precedence + identity links).
+
+What it means:
+- Channel-specific dmScope overrides must win over global defaults.
+- identityLinks should collapse only within explicit linked groups, not across unrelated peers.
+
+- Green:
+ - `make routing-precedence`
+ - `make routing-identitylinks`
+- Red (expected):
+ - `make routing-precedence-negative`
+ - `make routing-identitylinks-negative`
diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md
index 8ba2ea3f3..239b29966 100644
--- a/docs/start/getting-started.md
+++ b/docs/start/getting-started.md
@@ -180,7 +180,7 @@ If you don’t have a global install yet, run the onboarding step via `pnpm molt
Gateway (from this repo):
```bash
-node dist/entry.js gateway --port 18789 --verbose
+node moltbot.mjs gateway --port 18789 --verbose
```
## 7) Verify end-to-end
diff --git a/docs/tools/skills-config.md b/docs/tools/skills-config.md
index 3667b99cd..d233e8f21 100644
--- a/docs/tools/skills-config.md
+++ b/docs/tools/skills-config.md
@@ -60,7 +60,7 @@ Per-skill fields:
## Notes
- Keys under `entries` map to the skill name by default. If a skill defines
- `metadata.clawdbot.skillKey`, use that key instead.
+ `metadata.moltbot.skillKey`, use that key instead.
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
### Sandboxed skills + env vars
diff --git a/docs/tools/skills.md b/docs/tools/skills.md
index b99bc5660..0f72a8036 100644
--- a/docs/tools/skills.md
+++ b/docs/tools/skills.md
@@ -41,7 +41,7 @@ applies: workspace wins, then managed/local, then bundled.
Plugins can ship their own skills by listing `skills` directories in
`moltbot.plugin.json` (paths relative to the plugin root). Plugin skills load
when the plugin is enabled and participate in the normal skill precedence rules.
-You can gate them via `metadata.clawdbot.requires.config` on the plugin’s config
+You can gate them via `metadata.moltbot.requires.config` on the plugin’s config
entry. See [Plugins](/plugin) for discovery/config and [Tools](/tools) for the
tool surface those skills teach.
@@ -89,7 +89,7 @@ Notes:
- `metadata` should be a **single-line JSON object**.
- 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`).
+ - `homepage` — URL surfaced as “Website” in the macOS Skills UI (also supported via `metadata.moltbot.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).
- `command-dispatch` — `tool` (optional). When set to `tool`, the slash command bypasses the model and dispatches directly to a tool.
@@ -111,7 +111,7 @@ metadata: {"moltbot":{"requires":{"bins":["uv"],"env":["GEMINI_API_KEY"],"config
---
```
-Fields under `metadata.clawdbot`:
+Fields under `metadata.moltbot`:
- `always: true` — always include the skill (skip other gates).
- `emoji` — optional emoji used by the macOS Skills UI.
- `homepage` — optional URL shown as “Website” in the macOS Skills UI.
@@ -152,7 +152,7 @@ Notes:
- Go installs: if `go` is missing and `brew` is available, the gateway installs Go via Homebrew first and sets `GOBIN` to Homebrew’s `bin` when possible.
- Download installs: `url` (required), `archive` (`tar.gz` | `tar.bz2` | `zip`), `extract` (default: auto when archive detected), `stripComponents`, `targetDir` (default: `~/.clawdbot/tools/`).
-If no `metadata.clawdbot` is present, the skill is always eligible (unless
+If no `metadata.moltbot` is present, the skill is always eligible (unless
disabled in config or blocked by `skills.allowBundled` for bundled skills).
## Config overrides (`~/.clawdbot/moltbot.json`)
@@ -184,12 +184,12 @@ Bundled/managed skills can be toggled and supplied with env values:
Note: if the skill name contains hyphens, quote the key (JSON5 allows quoted keys).
Config keys match the **skill name** by default. If a skill defines
-`metadata.clawdbot.skillKey`, use that key under `skills.entries`.
+`metadata.moltbot.skillKey`, use that key under `skills.entries`.
Rules:
- `enabled: false` disables the skill even if it’s bundled/installed.
- `env`: injected **only if** the variable isn’t already set in the process.
-- `apiKey`: convenience for skills that declare `metadata.clawdbot.primaryEnv`.
+- `apiKey`: convenience for skills that declare `metadata.moltbot.primaryEnv`.
- `config`: optional bag for custom per-skill fields; custom keys must live here.
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only
bundled skills in the list are eligible (managed/workspace skills unaffected).
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index fc81c0c23..fc1ac34ae 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/bluebubbles",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot BlueBubbles channel plugin",
"moltbot": {
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index 7093b9c6d..2d4753446 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/copilot-proxy",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Copilot Proxy provider plugin",
"moltbot": {
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index 5f8b3643e..f6560702b 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/diagnostics-otel",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot diagnostics OpenTelemetry exporter",
"moltbot": {
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index c31e55e39..9921468b4 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/discord",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Discord channel plugin",
"moltbot": {
diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json
index 039b4871f..8b13861ec 100644
--- a/extensions/google-antigravity-auth/package.json
+++ b/extensions/google-antigravity-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/google-antigravity-auth",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Google Antigravity OAuth provider plugin",
"moltbot": {
diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json
index 0c268a773..59cbd52a9 100644
--- a/extensions/google-gemini-cli-auth/package.json
+++ b/extensions/google-gemini-cli-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/google-gemini-cli-auth",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Gemini CLI OAuth provider plugin",
"moltbot": {
diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json
index af3188e90..0a01621e6 100644
--- a/extensions/googlechat/package.json
+++ b/extensions/googlechat/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/googlechat",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Google Chat channel plugin",
"moltbot": {
diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json
index a298f1a1b..29ceb0631 100644
--- a/extensions/imessage/package.json
+++ b/extensions/imessage/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/imessage",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot iMessage channel plugin",
"moltbot": {
diff --git a/extensions/line/package.json b/extensions/line/package.json
index 803c7f74c..bd336b158 100644
--- a/extensions/line/package.json
+++ b/extensions/line/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/line",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot LINE channel plugin",
"moltbot": {
diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json
index 4a2a89a75..247d126a9 100644
--- a/extensions/llm-task/package.json
+++ b/extensions/llm-task/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/llm-task",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot JSON-only LLM task plugin",
"moltbot": {
diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json
index 513d83925..c95d7021a 100644
--- a/extensions/lobster/package.json
+++ b/extensions/lobster/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/lobster",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"moltbot": {
diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md
index 77aeba16c..8b7dcb62c 100644
--- a/extensions/matrix/CHANGELOG.md
+++ b/extensions/matrix/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Changes
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index d90a399c4..abc608b5b 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/matrix",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Matrix channel plugin",
"moltbot": {
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index 8d571ab76..6e7d3f1fc 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/mattermost",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Mattermost channel plugin",
"moltbot": {
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index 7c4ae5b01..e863adbd2 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/memory-core",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot core memory search plugin",
"moltbot": {
diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json
index 2b2858e02..0e79ce83a 100644
--- a/extensions/memory-lancedb/package.json
+++ b/extensions/memory-lancedb/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/memory-lancedb",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md
index f9b6e8b86..09a9e92bd 100644
--- a/extensions/msteams/CHANGELOG.md
+++ b/extensions/msteams/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Changes
diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json
index 8cc39b5d7..29e615862 100644
--- a/extensions/msteams/package.json
+++ b/extensions/msteams/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/msteams",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Microsoft Teams channel plugin",
"moltbot": {
diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json
index aea8b8942..5e98956da 100644
--- a/extensions/nextcloud-talk/package.json
+++ b/extensions/nextcloud-talk/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/nextcloud-talk",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Nextcloud Talk channel plugin",
"moltbot": {
diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md
index 57f073d0e..65ac7f56e 100644
--- a/extensions/nostr/CHANGELOG.md
+++ b/extensions/nostr/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Changes
diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json
index b932ac998..8ba9a48d0 100644
--- a/extensions/nostr/package.json
+++ b/extensions/nostr/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/nostr",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Nostr channel plugin for NIP-04 encrypted DMs",
"moltbot": {
diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json
index 4be78502d..89904fcca 100644
--- a/extensions/open-prose/package.json
+++ b/extensions/open-prose/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/open-prose",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"moltbot": {
diff --git a/extensions/signal/package.json b/extensions/signal/package.json
index db4976b49..105a4fee8 100644
--- a/extensions/signal/package.json
+++ b/extensions/signal/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/signal",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Signal channel plugin",
"moltbot": {
diff --git a/extensions/slack/package.json b/extensions/slack/package.json
index 352f15483..8ada7de5f 100644
--- a/extensions/slack/package.json
+++ b/extensions/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/slack",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Slack channel plugin",
"moltbot": {
diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json
index aff1bf081..0f485d029 100644
--- a/extensions/telegram/package.json
+++ b/extensions/telegram/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/telegram",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Telegram channel plugin",
"moltbot": {
diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json
index 85dbd2a8b..2df375b55 100644
--- a/extensions/tlon/package.json
+++ b/extensions/tlon/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/tlon",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Tlon/Urbit channel plugin",
"moltbot": {
diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md
index 2e291db10..95b5ff2c7 100644
--- a/extensions/twitch/CHANGELOG.md
+++ b/extensions/twitch/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Features
diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json
index cb79d2fbb..6654f9bb7 100644
--- a/extensions/twitch/package.json
+++ b/extensions/twitch/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/twitch",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"description": "Moltbot Twitch channel plugin",
"type": "module",
"dependencies": {
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index 0ece35f87..312e95917 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.26
### Changes
diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json
index 3b0294733..72bfba03d 100644
--- a/extensions/voice-call/package.json
+++ b/extensions/voice-call/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/voice-call",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot voice-call plugin",
"dependencies": {
diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json
index d64945784..d5139e18f 100644
--- a/extensions/whatsapp/package.json
+++ b/extensions/whatsapp/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/whatsapp",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot WhatsApp channel plugin",
"moltbot": {
diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md
index 03f128c28..55766ea8e 100644
--- a/extensions/zalo/CHANGELOG.md
+++ b/extensions/zalo/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Changes
diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json
index ca3c321a2..2a6cf9a5f 100644
--- a/extensions/zalo/package.json
+++ b/extensions/zalo/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/zalo",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Zalo channel plugin",
"moltbot": {
diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md
index 35cc9026d..e189e2e45 100644
--- a/extensions/zalouser/CHANGELOG.md
+++ b/extensions/zalouser/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.27-beta.1
+
+### Changes
+- Version alignment with core Moltbot release numbers.
+
## 2026.1.23
### Changes
diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json
index fad8a582a..6bace36e8 100644
--- a/extensions/zalouser/package.json
+++ b/extensions/zalouser/package.json
@@ -1,6 +1,6 @@
{
"name": "@moltbot/zalouser",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"type": "module",
"description": "Moltbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
diff --git a/moltbot.mjs b/moltbot.mjs
new file mode 100755
index 000000000..78992f94a
--- /dev/null
+++ b/moltbot.mjs
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+
+import module from "node:module";
+
+// https://nodejs.org/api/module.html#module-compile-cache
+if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
+ try {
+ module.enableCompileCache();
+ } catch {
+ // Ignore errors
+ }
+}
+
+await import("./dist/entry.js");
diff --git a/package.json b/package.json
index e1f1a8df7..04322f3af 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "moltbot",
- "version": "2026.1.26",
+ "version": "2026.1.27-beta.1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -8,11 +8,11 @@
".": "./dist/index.js",
"./plugin-sdk": "./dist/plugin-sdk/index.js",
"./plugin-sdk/*": "./dist/plugin-sdk/*",
- "./cli-entry": "./dist/entry.js"
+ "./cli-entry": "./moltbot.mjs"
},
"bin": {
- "moltbot": "dist/entry.js",
- "clawdbot": "dist/entry.js"
+ "moltbot": "./moltbot.mjs",
+ "clawdbot": "./moltbot.mjs"
},
"files": [
"dist/acp/**",
@@ -56,6 +56,7 @@
"docs/**",
"extensions/**",
"assets/**",
+ "moltbot.mjs",
"skills/**",
"patches/**",
"README.md",
diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh
index 7c5e96a84..d5be4fa86 100755
--- a/scripts/e2e/doctor-install-switch-docker.sh
+++ b/scripts/e2e/doctor-install-switch-docker.sh
@@ -81,8 +81,8 @@ LOGINCTL
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/moltbot"
- npm_entry="/tmp/npm-prefix/lib/node_modules/moltbot/dist/entry.js"
- git_entry="/app/dist/entry.js"
+ npm_entry="/tmp/npm-prefix/lib/node_modules/moltbot/moltbot.mjs"
+ git_entry="/app/moltbot.mjs"
assert_entrypoint() {
local unit_path="$1"
diff --git a/scripts/release-check.ts b/scripts/release-check.ts
index 5895bf7f9..d73850799 100755
--- a/scripts/release-check.ts
+++ b/scripts/release-check.ts
@@ -23,6 +23,7 @@ function runPackDry(): PackResult[] {
const raw = execSync("npm pack --dry-run --json --ignore-scripts", {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
+ maxBuffer: 1024 * 1024 * 100,
});
return JSON.parse(raw) as PackResult[];
}
diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh
index 6dc81bb4e..b9bf1ab86 100755
--- a/scripts/restart-mac.sh
+++ b/scripts/restart-mac.sh
@@ -96,8 +96,8 @@ for arg in "$@"; do
log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)"
log ""
log "Unsigned recovery:"
- log " node dist/entry.js daemon install --force --runtime node"
- log " node dist/entry.js daemon restart"
+ log " node moltbot.mjs daemon install --force --runtime node"
+ log " node moltbot.mjs daemon restart"
log ""
log "Reset unsigned overrides:"
log " rm ~/.clawdbot/disable-launchagent"
@@ -217,8 +217,8 @@ fi
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
# This reduces noisy "could not connect" errors during app startup.
if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
- run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
- run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
+ run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node moltbot.mjs daemon install --force --runtime node"
+ run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node moltbot.mjs daemon restart"
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
fi
diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs
index 0748a5991..b26f996a6 100644
--- a/scripts/run-node.mjs
+++ b/scripts/run-node.mjs
@@ -86,7 +86,7 @@ const logRunner = (message) => {
};
const runNode = () => {
- const nodeProcess = spawn(process.execPath, ["dist/entry.js", ...args], {
+ const nodeProcess = spawn(process.execPath, ["moltbot.mjs", ...args], {
cwd,
env,
stdio: "inherit",
@@ -95,7 +95,6 @@ const runNode = () => {
nodeProcess.on("exit", (exitCode, exitSignal) => {
if (exitSignal) {
process.exit(1);
- return;
}
process.exit(exitCode ?? 1);
});
@@ -128,11 +127,9 @@ if (!shouldBuild()) {
build.on("exit", (code, signal) => {
if (signal) {
process.exit(1);
- return;
}
if (code !== 0 && code !== null) {
process.exit(code);
- return;
}
writeBuildStamp();
runNode();
diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs
index 7ed210853..982a8c773 100644
--- a/scripts/watch-node.mjs
+++ b/scripts/watch-node.mjs
@@ -29,7 +29,7 @@ const compilerProcess = spawn("pnpm", ["exec", compiler, ...watchArgs], {
stdio: "inherit",
});
-const nodeProcess = spawn(process.execPath, ["--watch", "dist/entry.js", ...args], {
+const nodeProcess = spawn(process.execPath, ["--watch", "moltbot.mjs", ...args], {
cwd,
env,
stdio: "inherit",
diff --git a/src/agents/channel-tools.test.ts b/src/agents/channel-tools.test.ts
new file mode 100644
index 000000000..05ec460a7
--- /dev/null
+++ b/src/agents/channel-tools.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { MoltbotConfig } from "../config/config.js";
+import type { ChannelPlugin } from "../channels/plugins/types.js";
+import { setActivePluginRegistry } from "../plugins/runtime.js";
+import { createTestRegistry } from "../test-utils/channel-plugins.js";
+import { defaultRuntime } from "../runtime.js";
+import { __testing, listAllChannelSupportedActions } from "./channel-tools.js";
+
+describe("channel tools", () => {
+ const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
+
+ beforeEach(() => {
+ const plugin: ChannelPlugin = {
+ id: "test",
+ meta: {
+ id: "test",
+ label: "Test",
+ selectionLabel: "Test",
+ docsPath: "/channels/test",
+ blurb: "test plugin",
+ },
+ capabilities: { chatTypes: ["direct"] },
+ config: {
+ listAccountIds: () => [],
+ resolveAccount: () => ({}),
+ },
+ actions: {
+ listActions: () => {
+ throw new Error("boom");
+ },
+ },
+ };
+
+ __testing.resetLoggedListActionErrors();
+ errorSpy.mockClear();
+ setActivePluginRegistry(createTestRegistry([{ pluginId: "test", source: "test", plugin }]));
+ });
+
+ afterEach(() => {
+ setActivePluginRegistry(createTestRegistry([]));
+ errorSpy.mockClear();
+ });
+
+ it("skips crashing plugins and logs once", () => {
+ const cfg = {} as MoltbotConfig;
+ expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+
+ expect(listAllChannelSupportedActions({ cfg })).toEqual([]);
+ expect(errorSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/agents/channel-tools.ts b/src/agents/channel-tools.ts
index 437d326cb..27af3c5f9 100644
--- a/src/agents/channel-tools.ts
+++ b/src/agents/channel-tools.ts
@@ -1,8 +1,13 @@
import { getChannelDock } from "../channels/dock.js";
import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import { normalizeAnyChannelId } from "../channels/registry.js";
-import type { ChannelAgentTool, ChannelMessageActionName } from "../channels/plugins/types.js";
+import type {
+ ChannelAgentTool,
+ ChannelMessageActionName,
+ ChannelPlugin,
+} from "../channels/plugins/types.js";
import type { MoltbotConfig } from "../config/config.js";
+import { defaultRuntime } from "../runtime.js";
/**
* Get the list of supported message actions for a specific channel.
@@ -16,7 +21,7 @@ export function listChannelSupportedActions(params: {
const plugin = getChannelPlugin(params.channel as Parameters[0]);
if (!plugin?.actions?.listActions) return [];
const cfg = params.cfg ?? ({} as MoltbotConfig);
- return plugin.actions.listActions({ cfg });
+ return runPluginListActions(plugin, cfg);
}
/**
@@ -29,7 +34,7 @@ export function listAllChannelSupportedActions(params: {
for (const plugin of listChannelPlugins()) {
if (!plugin.actions?.listActions) continue;
const cfg = params.cfg ?? ({} as MoltbotConfig);
- const channelActions = plugin.actions.listActions({ cfg });
+ const channelActions = runPluginListActions(plugin, cfg);
for (const action of channelActions) {
actions.add(action);
}
@@ -64,3 +69,35 @@ export function resolveChannelMessageToolHints(params: {
.map((entry) => entry.trim())
.filter(Boolean);
}
+
+const loggedListActionErrors = new Set();
+
+function runPluginListActions(
+ plugin: ChannelPlugin,
+ cfg: MoltbotConfig,
+): ChannelMessageActionName[] {
+ if (!plugin.actions?.listActions) return [];
+ try {
+ const listed = plugin.actions.listActions({ cfg });
+ return Array.isArray(listed) ? listed : [];
+ } catch (err) {
+ logListActionsError(plugin.id, err);
+ return [];
+ }
+}
+
+function logListActionsError(pluginId: string, err: unknown) {
+ const message = err instanceof Error ? err.message : String(err);
+ const key = `${pluginId}:${message}`;
+ if (loggedListActionErrors.has(key)) return;
+ loggedListActionErrors.add(key);
+ const stack = err instanceof Error && err.stack ? err.stack : null;
+ const details = stack ?? message;
+ defaultRuntime.error?.(`[channel-tools] ${pluginId}.actions.listActions failed: ${details}`);
+}
+
+export const __testing = {
+ resetLoggedListActionErrors() {
+ loggedListActionErrors.clear();
+ },
+};
diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts
index 76f1c3acd..a176dac8a 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -17,7 +17,7 @@ import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
type ModelsConfig = NonNullable;
export type ProviderConfig = NonNullable[string];
-const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic";
+const MINIMAX_API_BASE_URL = "https://api.minimax.chat/v1";
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.1";
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
@@ -31,7 +31,7 @@ const MINIMAX_API_COST = {
};
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
-const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2-0905-preview";
+const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
const MOONSHOT_DEFAULT_COST = {
@@ -244,7 +244,7 @@ export function normalizeProviders(params: {
function buildMinimaxProvider(): ProviderConfig {
return {
baseUrl: MINIMAX_API_BASE_URL,
- api: "anthropic-messages",
+ api: "openai-completions",
models: [
{
id: MINIMAX_DEFAULT_MODEL_ID,
@@ -275,7 +275,7 @@ function buildMoonshotProvider(): ProviderConfig {
models: [
{
id: MOONSHOT_DEFAULT_MODEL_ID,
- name: "Kimi K2 0905 Preview",
+ name: "Kimi K2.5",
reasoning: false,
input: ["text"],
cost: MOONSHOT_DEFAULT_COST,
diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts
index 270b5fb02..fef8fa6a4 100644
--- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts
+++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts
@@ -136,7 +136,7 @@ describe("models-config", () => {
}
>;
};
- expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
+ expect(parsed.providers.minimax?.baseUrl).toBe("https://api.minimax.chat/v1");
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-M2.1");
diff --git a/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts b/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts
index bb449a6e4..749a52414 100644
--- a/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts
+++ b/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts
@@ -31,6 +31,7 @@ describe("classifyFailoverReason", () => {
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
+ expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull();
});
it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
diff --git a/src/agents/pi-embedded-helpers.image-size-error.test.ts b/src/agents/pi-embedded-helpers.image-size-error.test.ts
new file mode 100644
index 000000000..75b165d8d
--- /dev/null
+++ b/src/agents/pi-embedded-helpers.image-size-error.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import { parseImageSizeError } from "./pi-embedded-helpers.js";
+
+describe("parseImageSizeError", () => {
+ it("parses max MB values from error text", () => {
+ expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5);
+ expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5);
+ });
+
+ it("returns null for unrelated errors", () => {
+ expect(parseImageSizeError("context overflow")).toBeNull();
+ });
+});
diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts
index 6f6bb474f..88443756f 100644
--- a/src/agents/pi-embedded-helpers.ts
+++ b/src/agents/pi-embedded-helpers.ts
@@ -23,12 +23,14 @@ export {
isFailoverAssistantError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
+ isImageSizeError,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,
isRateLimitErrorMessage,
isTimeoutErrorMessage,
parseImageDimensionError,
+ parseImageSizeError,
} from "./pi-embedded-helpers/errors.js";
export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js";
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index d6e33f924..849c4293e 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -401,6 +401,7 @@ const ERROR_PATTERNS = {
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
+const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i;
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false;
@@ -467,6 +468,25 @@ export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
+export function parseImageSizeError(raw: string): {
+ maxMb?: number;
+ raw: string;
+} | null {
+ if (!raw) return null;
+ const lower = raw.toLowerCase();
+ if (!lower.includes("image exceeds") || !lower.includes("mb")) return null;
+ const match = raw.match(IMAGE_SIZE_ERROR_RE);
+ return {
+ maxMb: match?.[1] ? Number.parseFloat(match[1]) : undefined,
+ raw,
+ };
+}
+
+export function isImageSizeError(errorMessage?: string): boolean {
+ if (!errorMessage) return false;
+ return Boolean(parseImageSizeError(errorMessage));
+}
+
export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
@@ -478,6 +498,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
+ if (isImageSizeError(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";
diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts
index 69eb1514a..870453f38 100644
--- a/src/agents/pi-embedded-runner/run.ts
+++ b/src/agents/pi-embedded-runner/run.ts
@@ -34,6 +34,7 @@ import {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
+ parseImageSizeError,
parseImageDimensionError,
isRateLimitAssistantError,
isTimeoutErrorMessage,
@@ -440,6 +441,34 @@ export async function runEmbeddedPiAgent(
},
};
}
+ // Handle image size errors with a user-friendly message (no retry needed)
+ const imageSizeError = parseImageSizeError(errorText);
+ if (imageSizeError) {
+ const maxMb = imageSizeError.maxMb;
+ const maxMbLabel =
+ typeof maxMb === "number" && Number.isFinite(maxMb) ? `${maxMb}` : null;
+ const maxBytesHint = maxMbLabel ? ` (max ${maxMbLabel}MB)` : "";
+ return {
+ payloads: [
+ {
+ text:
+ `Image too large for the model${maxBytesHint}. ` +
+ "Please compress or resize the image and try again.",
+ isError: true,
+ },
+ ],
+ meta: {
+ durationMs: Date.now() - started,
+ agentMeta: {
+ sessionId: sessionIdUsed,
+ provider,
+ model: model.id,
+ },
+ systemPromptReport: attempt.systemPromptReport,
+ error: { kind: "image_size", message: errorText },
+ },
+ };
+ }
const promptFailoverReason = classifyFailoverReason(errorText);
if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) {
await markAuthProfileFailure({
diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts
index 4be395bce..27ccfa64e 100644
--- a/src/agents/pi-embedded-runner/types.ts
+++ b/src/agents/pi-embedded-runner/types.ts
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
aborted?: boolean;
systemPromptReport?: SessionSystemPromptReport;
error?: {
- kind: "context_overflow" | "compaction_failure" | "role_ordering";
+ kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size";
message: string;
};
/** Stop reason for the agent run (e.g., "completed", "tool_calls"). */
diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts
index 0e4579d6d..2b4e1aea1 100644
--- a/src/agents/tools/image-tool.test.ts
+++ b/src/agents/tools/image-tool.test.ts
@@ -275,7 +275,7 @@ describe("image tool MiniMax VLM routing", () => {
expect(fetch).toHaveBeenCalledTimes(1);
const [url, init] = fetch.mock.calls[0];
- expect(String(url)).toBe("https://api.minimax.io/v1/coding_plan/vlm");
+ expect(String(url)).toBe("https://api.minimax.chat/v1/coding_plan/vlm");
expect(init?.method).toBe("POST");
expect(String((init?.headers as Record)?.Authorization)).toBe(
"Bearer minimax-test",
diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts
index 3ea06ce88..9ae14d38f 100644
--- a/src/agents/transcript-policy.ts
+++ b/src/agents/transcript-policy.ts
@@ -51,7 +51,8 @@ function isOpenAiProvider(provider?: string | null): boolean {
function isAnthropicApi(modelApi?: string | null, provider?: string | null): boolean {
if (modelApi === "anthropic-messages") return true;
const normalized = normalizeProviderId(provider ?? "");
- return normalized === "anthropic" || normalized === "minimax";
+ // MiniMax now uses openai-completions API, not anthropic-messages
+ return normalized === "anthropic";
}
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
diff --git a/src/channels/targets.ts b/src/channels/targets.ts
index 77ab755b7..7c9d9cf60 100644
--- a/src/channels/targets.ts
+++ b/src/channels/targets.ts
@@ -1,3 +1,6 @@
+export type { DirectoryConfigParams } from "./plugins/directory-config.js";
+export type { ChannelDirectoryEntry } from "./plugins/types.js";
+
export type MessagingTargetKind = "user" | "channel";
export type MessagingTarget = {
diff --git a/src/cli/banner.ts b/src/cli/banner.ts
index 0d9c435c8..6ca7d4cbc 100644
--- a/src/cli/banner.ts
+++ b/src/cli/banner.ts
@@ -65,12 +65,12 @@ export function formatCliBannerLine(version: string, options: BannerOptions = {}
}
const LOBSTER_ASCII = [
- "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
- "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
- "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
- "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
- "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
- " 🦞 FRESH DAILY 🦞",
+ "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
+ "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
+ "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
+ "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
+ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
+ " 🦞 FRESH DAILY 🦞 ",
];
export function formatCliBannerArt(options: BannerOptions = {}): string {
diff --git a/src/cli/gateway-cli/dev.ts b/src/cli/gateway-cli/dev.ts
index cc754c4dd..565df14b8 100644
--- a/src/cli/gateway-cli/dev.ts
+++ b/src/cli/gateway-cli/dev.ts
@@ -4,7 +4,7 @@ import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js";
-import { CONFIG_PATH, writeConfigFile } from "../../config/config.js";
+import { createConfigIO, writeConfigFile } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath, shortenHomePath } from "../../utils.js";
@@ -89,7 +89,9 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
await handleReset("full", workspace, defaultRuntime);
}
- const configExists = fs.existsSync(CONFIG_PATH);
+ const io = createConfigIO();
+ const configPath = io.configPath;
+ const configExists = fs.existsSync(configPath);
if (!opts.reset && configExists) return;
await writeConfigFile({
@@ -117,6 +119,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
},
});
await ensureDevWorkspace(workspace);
- defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH)}`);
+ defaultRuntime.log(`Dev config ready: ${shortenHomePath(configPath)}`);
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
}
diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts
index 0f4d4e9b7..cb26aa98d 100644
--- a/src/cli/gateway-cli/run.ts
+++ b/src/cli/gateway-cli/run.ts
@@ -157,7 +157,8 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
const passwordRaw = toOptionString(opts.password);
const tokenRaw = toOptionString(opts.token);
- const configExists = fs.existsSync(CONFIG_PATH);
+ const snapshot = await readConfigFileSnapshot().catch(() => null);
+ const configExists = snapshot?.exists ?? fs.existsSync(CONFIG_PATH);
const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") {
if (!configExists) {
@@ -187,7 +188,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
return;
}
- const snapshot = await readConfigFileSnapshot().catch(() => null);
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
const authConfig = {
...cfg.gateway?.auth,
diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts
index 97ca4508a..e5684fbea 100644
--- a/src/cli/program/register.subclis.ts
+++ b/src/cli/program/register.subclis.ts
@@ -168,6 +168,11 @@ const entries: SubCliEntry[] = [
name: "pairing",
description: "Pairing helpers",
register: async (program) => {
+ // Initialize plugins before registering pairing CLI.
+ // The pairing CLI calls listPairingChannels() at registration time,
+ // which requires the plugin registry to be populated with channel plugins.
+ const { registerPluginCliCommands } = await import("../../plugins/cli.js");
+ registerPluginCliCommands(program, await loadConfig());
const mod = await import("../pairing-cli.js");
mod.registerPairingCli(program);
},
diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts
index 879c8679e..fda4673d9 100644
--- a/src/commands/doctor-config-flow.ts
+++ b/src/commands/doctor-config-flow.ts
@@ -1,3 +1,5 @@
+import fs from "node:fs";
+import path from "node:path";
import type { ZodIssue } from "zod";
import type { MoltbotConfig } from "../config/config.js";
@@ -12,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js";
import { note } from "../terminal/note.js";
import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions } from "./doctor-prompter.js";
+import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
function isRecord(value: unknown): value is Record {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -117,12 +120,50 @@ function noteOpencodeProviderOverrides(cfg: MoltbotConfig) {
note(lines.join("\n"), "OpenCode Zen");
}
+function hasExplicitConfigPath(env: NodeJS.ProcessEnv): boolean {
+ return Boolean(env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim());
+}
+
+function moveLegacyConfigFile(legacyPath: string, canonicalPath: string) {
+ fs.mkdirSync(path.dirname(canonicalPath), { recursive: true, mode: 0o700 });
+ try {
+ fs.renameSync(legacyPath, canonicalPath);
+ } catch {
+ fs.copyFileSync(legacyPath, canonicalPath);
+ fs.chmodSync(canonicalPath, 0o600);
+ try {
+ fs.unlinkSync(legacyPath);
+ } catch {
+ // Best-effort cleanup; we'll warn later if both files exist.
+ }
+ }
+}
+
export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise;
}) {
const shouldRepair = params.options.repair === true || params.options.yes === true;
- const snapshot = await readConfigFileSnapshot();
+ const stateDirResult = await autoMigrateLegacyStateDir({ env: process.env });
+ if (stateDirResult.changes.length > 0) {
+ note(stateDirResult.changes.map((entry) => `- ${entry}`).join("\n"), "Doctor changes");
+ }
+ if (stateDirResult.warnings.length > 0) {
+ note(stateDirResult.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
+ }
+
+ let snapshot = await readConfigFileSnapshot();
+ if (!hasExplicitConfigPath(process.env) && snapshot.exists) {
+ const basename = path.basename(snapshot.path);
+ if (basename === "clawdbot.json") {
+ const canonicalPath = path.join(path.dirname(snapshot.path), "moltbot.json");
+ if (!fs.existsSync(canonicalPath)) {
+ moveLegacyConfigFile(snapshot.path, canonicalPath);
+ note(`- Config: ${snapshot.path} → ${canonicalPath}`, "Doctor changes");
+ snapshot = await readConfigFileSnapshot();
+ }
+ }
+ }
const baseCfg = snapshot.config ?? {};
let cfg: MoltbotConfig = baseCfg;
let candidate = structuredClone(baseCfg) as MoltbotConfig;
diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts
index bf2c94da7..856b18bfb 100644
--- a/src/commands/doctor-security.ts
+++ b/src/commands/doctor-security.ts
@@ -124,7 +124,7 @@ export async function noteSecurityWarnings(cfg: MoltbotConfig) {
if (dmScope === "main" && isMultiUserDm) {
warnings.push(
- `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" to isolate sessions.`,
+ `- ${params.label} DMs: multiple senders share the main session; set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.`,
);
}
};
diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts
index 15ba11804..2ae7faf05 100644
--- a/src/commands/doctor-state-migrations.test.ts
+++ b/src/commands/doctor-state-migrations.test.ts
@@ -6,8 +6,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { MoltbotConfig } from "../config/config.js";
import {
+ autoMigrateLegacyStateDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
+ resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
@@ -22,6 +24,7 @@ async function makeTempRoot() {
afterEach(async () => {
resetAutoMigrateLegacyStateForTest();
+ resetAutoMigrateLegacyStateDirForTest();
if (!tempRoot) return;
await fs.promises.rm(tempRoot, { recursive: true, force: true });
tempRoot = null;
@@ -323,4 +326,53 @@ describe("doctor legacy state migrations", () => {
expect(store["main"]).toBeUndefined();
expect(store["agent:main:main"]?.sessionId).toBe("legacy");
});
+
+ it("auto-migrates legacy state dir to ~/.moltbot", async () => {
+ const root = await makeTempRoot();
+ const legacyDir = path.join(root, ".clawdbot");
+ fs.mkdirSync(legacyDir, { recursive: true });
+ fs.writeFileSync(path.join(legacyDir, "foo.txt"), "legacy", "utf-8");
+
+ const result = await autoMigrateLegacyStateDir({
+ env: {} as NodeJS.ProcessEnv,
+ homedir: () => root,
+ });
+
+ const targetDir = path.join(root, ".moltbot");
+ expect(fs.existsSync(path.join(targetDir, "foo.txt"))).toBe(true);
+ const legacyStat = fs.lstatSync(legacyDir);
+ expect(legacyStat.isSymbolicLink()).toBe(true);
+ expect(fs.realpathSync(legacyDir)).toBe(fs.realpathSync(targetDir));
+ expect(result.migrated).toBe(true);
+ });
+
+ it("skips state dir migration when target exists", async () => {
+ const root = await makeTempRoot();
+ const legacyDir = path.join(root, ".clawdbot");
+ const targetDir = path.join(root, ".moltbot");
+ fs.mkdirSync(legacyDir, { recursive: true });
+ fs.mkdirSync(targetDir, { recursive: true });
+
+ const result = await autoMigrateLegacyStateDir({
+ env: {} as NodeJS.ProcessEnv,
+ homedir: () => root,
+ });
+
+ expect(result.migrated).toBe(false);
+ expect(result.warnings.length).toBeGreaterThan(0);
+ });
+
+ it("skips state dir migration when env override is set", async () => {
+ const root = await makeTempRoot();
+ const legacyDir = path.join(root, ".clawdbot");
+ fs.mkdirSync(legacyDir, { recursive: true });
+
+ const result = await autoMigrateLegacyStateDir({
+ env: { MOLTBOT_STATE_DIR: "/custom/state" } as NodeJS.ProcessEnv,
+ homedir: () => root,
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.migrated).toBe(false);
+ });
});
diff --git a/src/commands/doctor-state-migrations.ts b/src/commands/doctor-state-migrations.ts
index 7448b8cd7..50c59a3a0 100644
--- a/src/commands/doctor-state-migrations.ts
+++ b/src/commands/doctor-state-migrations.ts
@@ -1,9 +1,11 @@
export type { LegacyStateDetection } from "../infra/state-migrations.js";
export {
+ autoMigrateLegacyStateDir,
autoMigrateLegacyAgentDir,
autoMigrateLegacyState,
detectLegacyStateMigrations,
migrateLegacyAgentDir,
+ resetAutoMigrateLegacyStateDirForTest,
resetAutoMigrateLegacyAgentDirForTest,
resetAutoMigrateLegacyStateForTest,
runLegacyStateMigrations,
diff --git a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts
index 08af35e90..7ddcc2049 100644
--- a/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts
+++ b/src/commands/doctor.falls-back-legacy-sandbox-image-missing.test.ts
@@ -292,6 +292,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+ }),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
diff --git a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts
index b6cb0c988..4f6651251 100644
--- a/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts
+++ b/src/commands/doctor.migrates-routing-allowfrom-channels-whatsapp-allowfrom.test.ts
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+ }),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
diff --git a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts
index 677813bc1..f36b85b29 100644
--- a/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts
+++ b/src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+ }),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
diff --git a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts
index 980ddc8dc..d2d232606 100644
--- a/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts
+++ b/src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+ }),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
diff --git a/src/commands/doctor.warns-state-directory-is-missing.test.ts b/src/commands/doctor.warns-state-directory-is-missing.test.ts
index 4bbc938fc..10b9e8a67 100644
--- a/src/commands/doctor.warns-state-directory-is-missing.test.ts
+++ b/src/commands/doctor.warns-state-directory-is-missing.test.ts
@@ -291,6 +291,12 @@ vi.mock("./onboard-helpers.js", () => ({
}));
vi.mock("./doctor-state-migrations.js", () => ({
+ autoMigrateLegacyStateDir: vi.fn().mockResolvedValue({
+ migrated: false,
+ skipped: false,
+ changes: [],
+ warnings: [],
+ }),
detectLegacyStateMigrations: vi.fn().mockResolvedValue({
targetAgentId: "main",
targetMainKey: "main",
diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts
index e1f8dbe8e..27ec07de4 100644
--- a/src/commands/onboard-channels.ts
+++ b/src/commands/onboard-channels.ts
@@ -190,7 +190,7 @@ async function noteChannelPrimer(
"DM security: default is pairing; unknown DMs get a pairing code.",
`Approve with: ${formatCliCommand("moltbot pairing approve ")}`,
'Public DMs require dmPolicy="open" + allowFrom=["*"].',
- 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
+ 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
"",
...channelLines,
@@ -238,7 +238,7 @@ async function maybeConfigureDmPolicies(params: {
`Approve: ${formatCliCommand(`moltbot pairing approve ${policy.channel} `)}`,
`Allowlist DMs: ${policy.policyKey}="allowlist" + ${policy.allowFromKey} entries.`,
`Public DMs: ${policy.policyKey}="open" + ${policy.allowFromKey} includes "*".`,
- 'Multi-user DMs: set session.dmScope="per-channel-peer" to isolate sessions.',
+ 'Multi-user DMs: set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate sessions.',
`Docs: ${formatDocsLink("/start/pairing", "start/pairing")}`,
].join("\n"),
`${policy.label} DM access`,
diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts
index 03fe77a27..165365bb6 100644
--- a/src/commands/onboard-helpers.ts
+++ b/src/commands/onboard-helpers.ts
@@ -64,12 +64,12 @@ export function randomToken(): string {
export function printWizardHeader(runtime: RuntimeEnv) {
const header = [
- "░████░█░░░░░█████░█░░░█░███░░████░░████░░▀█▀",
- "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░░█░█░░░█░░█░",
- "█░░░░░█░░░░░█████░█░█░█░█░░█░████░░█░░░█░░█░",
- "█░░░░░█░░░░░█░░░█░█░█░█░█░░█░█░░█░░█░░░█░░█░",
- "░████░█████░█░░░█░░█░█░░███░░████░░░███░░░█░",
- " 🦞 FRESH DAILY 🦞",
+ "▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄",
+ "██░▄▀▄░██░▄▄▄░██░████▄▄░▄▄██░▄▄▀██░▄▄▄░█▄▄░▄▄██",
+ "██░█░█░██░███░██░██████░████░▄▄▀██░███░███░████",
+ "██░███░██░▀▀▀░██░▀▀░███░████░▀▀░██░▀▀▀░███░████",
+ "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
+ " 🦞 FRESH DAILY 🦞 ",
].join("\n");
runtime.log(header);
}
diff --git a/src/commands/setup.ts b/src/commands/setup.ts
index ad1d4ec38..2f3ea90c7 100644
--- a/src/commands/setup.ts
+++ b/src/commands/setup.ts
@@ -3,19 +3,19 @@ import fs from "node:fs/promises";
import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
-import { type MoltbotConfig, CONFIG_PATH, writeConfigFile } from "../config/config.js";
+import { type MoltbotConfig, createConfigIO, writeConfigFile } from "../config/config.js";
import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
-async function readConfigFileRaw(): Promise<{
+async function readConfigFileRaw(configPath: string): Promise<{
exists: boolean;
parsed: MoltbotConfig;
}> {
try {
- const raw = await fs.readFile(CONFIG_PATH, "utf-8");
+ const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") {
return { exists: true, parsed: parsed as MoltbotConfig };
@@ -35,7 +35,9 @@ export async function setupCommand(
? opts.workspace.trim()
: undefined;
- const existingRaw = await readConfigFileRaw();
+ const io = createConfigIO();
+ const configPath = io.configPath;
+ const existingRaw = await readConfigFileRaw(configPath);
const cfg = existingRaw.parsed;
const defaults = cfg.agents?.defaults ?? {};
@@ -55,12 +57,12 @@ export async function setupCommand(
if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next);
if (!existingRaw.exists) {
- runtime.log(`Wrote ${formatConfigPath()}`);
+ runtime.log(`Wrote ${formatConfigPath(configPath)}`);
} else {
- logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" });
+ logConfigUpdated(runtime, { path: configPath, suffix: "(set agents.defaults.workspace)" });
}
} else {
- runtime.log(`Config OK: ${formatConfigPath()}`);
+ runtime.log(`Config OK: ${formatConfigPath(configPath)}`);
}
const ws = await ensureAgentWorkspace({
diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts
index 4a32658ae..fd98f2650 100644
--- a/src/config/io.compat.test.ts
+++ b/src/config/io.compat.test.ts
@@ -14,10 +14,15 @@ async function withTempHome(run: (home: string) => Promise): Promise
}
}
-async function writeConfig(home: string, dirname: ".moltbot" | ".clawdbot", port: number) {
+async function writeConfig(
+ home: string,
+ dirname: ".moltbot" | ".clawdbot",
+ port: number,
+ filename: "moltbot.json" | "clawdbot.json" = "moltbot.json",
+) {
const dir = path.join(home, dirname);
await fs.mkdir(dir, { recursive: true });
- const configPath = path.join(dir, "moltbot.json");
+ const configPath = path.join(dir, filename);
await fs.writeFile(configPath, JSON.stringify({ gateway: { port } }, null, 2));
return configPath;
}
@@ -51,6 +56,35 @@ describe("config io compat (new + legacy folders)", () => {
});
});
+ it("falls back to ~/.clawdbot/clawdbot.json when only legacy filename exists", async () => {
+ await withTempHome(async (home) => {
+ const legacyConfigPath = await writeConfig(home, ".clawdbot", 20002, "clawdbot.json");
+
+ const io = createConfigIO({
+ env: {} as NodeJS.ProcessEnv,
+ homedir: () => home,
+ });
+
+ expect(io.configPath).toBe(legacyConfigPath);
+ expect(io.loadConfig().gateway?.port).toBe(20002);
+ });
+ });
+
+ it("prefers moltbot.json over legacy filename in the same dir", async () => {
+ await withTempHome(async (home) => {
+ const preferred = await writeConfig(home, ".clawdbot", 20003, "moltbot.json");
+ await writeConfig(home, ".clawdbot", 20004, "clawdbot.json");
+
+ const io = createConfigIO({
+ env: {} as NodeJS.ProcessEnv,
+ homedir: () => home,
+ });
+
+ expect(io.configPath).toBe(preferred);
+ expect(io.loadConfig().gateway?.port).toBe(20003);
+ });
+ });
+
it("honors explicit legacy config path env override", async () => {
await withTempHome(async (home) => {
const newConfigPath = await writeConfig(home, ".moltbot", 19002);
diff --git a/src/config/io.ts b/src/config/io.ts
index ef8ffba86..50f1edb82 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -555,7 +555,8 @@ function clearConfigCache(): void {
}
export function loadConfig(): MoltbotConfig {
- const configPath = resolveConfigPath();
+ const io = createConfigIO();
+ const configPath = io.configPath;
const now = Date.now();
if (shouldUseConfigCache(process.env)) {
const cached = configCache;
@@ -563,7 +564,7 @@ export function loadConfig(): MoltbotConfig {
return cached.config;
}
}
- const config = createConfigIO({ configPath }).loadConfig();
+ const config = io.loadConfig();
if (shouldUseConfigCache(process.env)) {
const cacheMs = resolveConfigCacheMs(process.env);
if (cacheMs > 0) {
@@ -578,12 +579,10 @@ export function loadConfig(): MoltbotConfig {
}
export async function readConfigFileSnapshot(): Promise {
- return await createConfigIO({
- configPath: resolveConfigPath(),
- }).readConfigFileSnapshot();
+ return await createConfigIO().readConfigFileSnapshot();
}
export async function writeConfigFile(cfg: MoltbotConfig): Promise {
clearConfigCache();
- await createConfigIO({ configPath: resolveConfigPath() }).writeConfigFile(cfg);
+ await createConfigIO().writeConfigFile(cfg);
}
diff --git a/src/config/paths.test.ts b/src/config/paths.test.ts
index f99e88513..806d29f92 100644
--- a/src/config/paths.test.ts
+++ b/src/config/paths.test.ts
@@ -1,8 +1,11 @@
+import fs from "node:fs/promises";
+import os from "node:os";
import path from "node:path";
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
resolveDefaultConfigCandidates,
+ resolveConfigPath,
resolveOAuthDir,
resolveOAuthPath,
resolveStateDir,
@@ -47,6 +50,93 @@ describe("state + config path candidates", () => {
const home = "/home/test";
const candidates = resolveDefaultConfigCandidates({} as NodeJS.ProcessEnv, () => home);
expect(candidates[0]).toBe(path.join(home, ".moltbot", "moltbot.json"));
- expect(candidates[1]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
+ expect(candidates[1]).toBe(path.join(home, ".moltbot", "clawdbot.json"));
+ expect(candidates[2]).toBe(path.join(home, ".clawdbot", "moltbot.json"));
+ expect(candidates[3]).toBe(path.join(home, ".clawdbot", "clawdbot.json"));
+ });
+
+ it("prefers ~/.moltbot when it exists and legacy dir is missing", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-state-"));
+ try {
+ const newDir = path.join(root, ".moltbot");
+ await fs.mkdir(newDir, { recursive: true });
+ const resolved = resolveStateDir({} as NodeJS.ProcessEnv, () => root);
+ expect(resolved).toBe(newDir);
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("CONFIG_PATH prefers existing legacy filename when present", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-"));
+ const previousHome = process.env.HOME;
+ const previousUserProfile = process.env.USERPROFILE;
+ const previousHomeDrive = process.env.HOMEDRIVE;
+ const previousHomePath = process.env.HOMEPATH;
+ const previousMoltbotConfig = process.env.MOLTBOT_CONFIG_PATH;
+ const previousClawdbotConfig = process.env.CLAWDBOT_CONFIG_PATH;
+ const previousMoltbotState = process.env.MOLTBOT_STATE_DIR;
+ const previousClawdbotState = process.env.CLAWDBOT_STATE_DIR;
+ try {
+ const legacyDir = path.join(root, ".clawdbot");
+ await fs.mkdir(legacyDir, { recursive: true });
+ const legacyPath = path.join(legacyDir, "clawdbot.json");
+ await fs.writeFile(legacyPath, "{}", "utf-8");
+
+ process.env.HOME = root;
+ if (process.platform === "win32") {
+ process.env.USERPROFILE = root;
+ const parsed = path.win32.parse(root);
+ process.env.HOMEDRIVE = parsed.root.replace(/\\$/, "");
+ process.env.HOMEPATH = root.slice(parsed.root.length - 1);
+ }
+ delete process.env.MOLTBOT_CONFIG_PATH;
+ delete process.env.CLAWDBOT_CONFIG_PATH;
+ delete process.env.MOLTBOT_STATE_DIR;
+ delete process.env.CLAWDBOT_STATE_DIR;
+
+ vi.resetModules();
+ const { CONFIG_PATH } = await import("./paths.js");
+ expect(CONFIG_PATH).toBe(legacyPath);
+ } finally {
+ if (previousHome === undefined) {
+ delete process.env.HOME;
+ } else {
+ process.env.HOME = previousHome;
+ }
+ if (previousUserProfile === undefined) delete process.env.USERPROFILE;
+ else process.env.USERPROFILE = previousUserProfile;
+ if (previousHomeDrive === undefined) delete process.env.HOMEDRIVE;
+ else process.env.HOMEDRIVE = previousHomeDrive;
+ if (previousHomePath === undefined) delete process.env.HOMEPATH;
+ else process.env.HOMEPATH = previousHomePath;
+ if (previousMoltbotConfig === undefined) delete process.env.MOLTBOT_CONFIG_PATH;
+ else process.env.MOLTBOT_CONFIG_PATH = previousMoltbotConfig;
+ if (previousClawdbotConfig === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
+ else process.env.CLAWDBOT_CONFIG_PATH = previousClawdbotConfig;
+ if (previousMoltbotState === undefined) delete process.env.MOLTBOT_STATE_DIR;
+ else process.env.MOLTBOT_STATE_DIR = previousMoltbotState;
+ if (previousClawdbotState === undefined) delete process.env.CLAWDBOT_STATE_DIR;
+ else process.env.CLAWDBOT_STATE_DIR = previousClawdbotState;
+ await fs.rm(root, { recursive: true, force: true });
+ vi.resetModules();
+ }
+ });
+
+ it("respects state dir overrides when config is missing", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-config-override-"));
+ try {
+ const legacyDir = path.join(root, ".clawdbot");
+ await fs.mkdir(legacyDir, { recursive: true });
+ const legacyConfig = path.join(legacyDir, "moltbot.json");
+ await fs.writeFile(legacyConfig, "{}", "utf-8");
+
+ const overrideDir = path.join(root, "override");
+ const env = { MOLTBOT_STATE_DIR: overrideDir } as NodeJS.ProcessEnv;
+ const resolved = resolveConfigPath(env, overrideDir, () => root);
+ expect(resolved).toBe(path.join(overrideDir, "moltbot.json"));
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
});
});
diff --git a/src/config/paths.ts b/src/config/paths.ts
index 2fc3937c4..f6e451596 100644
--- a/src/config/paths.ts
+++ b/src/config/paths.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { MoltbotConfig } from "./types.js";
@@ -18,6 +19,7 @@ export const isNixMode = resolveIsNixMode();
const LEGACY_STATE_DIRNAME = ".clawdbot";
const NEW_STATE_DIRNAME = ".moltbot";
const CONFIG_FILENAME = "moltbot.json";
+const LEGACY_CONFIG_FILENAME = "clawdbot.json";
function legacyStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), LEGACY_STATE_DIRNAME);
@@ -27,10 +29,19 @@ function newStateDir(homedir: () => string = os.homedir): string {
return path.join(homedir(), NEW_STATE_DIRNAME);
}
+export function resolveLegacyStateDir(homedir: () => string = os.homedir): string {
+ return legacyStateDir(homedir);
+}
+
+export function resolveNewStateDir(homedir: () => string = os.homedir): string {
+ return newStateDir(homedir);
+}
+
/**
* State directory for mutable data (sessions, logs, caches).
* Can be overridden via MOLTBOT_STATE_DIR (preferred) or CLAWDBOT_STATE_DIR (legacy).
* Default: ~/.clawdbot (legacy default for compatibility)
+ * If ~/.moltbot exists and ~/.clawdbot does not, prefer ~/.moltbot.
*/
export function resolveStateDir(
env: NodeJS.ProcessEnv = process.env,
@@ -38,7 +49,12 @@ export function resolveStateDir(
): string {
const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
- return legacyStateDir(homedir);
+ const legacyDir = legacyStateDir(homedir);
+ const newDir = newStateDir(homedir);
+ const hasLegacy = fs.existsSync(legacyDir);
+ const hasNew = fs.existsSync(newDir);
+ if (!hasLegacy && hasNew) return newDir;
+ return legacyDir;
}
function resolveUserPath(input: string): string {
@@ -58,7 +74,7 @@ export const STATE_DIR = resolveStateDir();
* Can be overridden via MOLTBOT_CONFIG_PATH (preferred) or CLAWDBOT_CONFIG_PATH (legacy).
* Default: ~/.clawdbot/moltbot.json (or $*_STATE_DIR/moltbot.json)
*/
-export function resolveConfigPath(
+export function resolveCanonicalConfigPath(
env: NodeJS.ProcessEnv = process.env,
stateDir: string = resolveStateDir(env, os.homedir),
): string {
@@ -67,7 +83,58 @@ export function resolveConfigPath(
return path.join(stateDir, CONFIG_FILENAME);
}
-export const CONFIG_PATH = resolveConfigPath();
+/**
+ * Resolve the active config path by preferring existing config candidates
+ * (new/legacy filenames) before falling back to the canonical path.
+ */
+export function resolveConfigPathCandidate(
+ env: NodeJS.ProcessEnv = process.env,
+ homedir: () => string = os.homedir,
+): string {
+ const candidates = resolveDefaultConfigCandidates(env, homedir);
+ const existing = candidates.find((candidate) => {
+ try {
+ return fs.existsSync(candidate);
+ } catch {
+ return false;
+ }
+ });
+ if (existing) return existing;
+ return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir));
+}
+
+/**
+ * Active config path (prefers existing legacy/new config files).
+ */
+export function resolveConfigPath(
+ env: NodeJS.ProcessEnv = process.env,
+ stateDir: string = resolveStateDir(env, os.homedir),
+ homedir: () => string = os.homedir,
+): string {
+ const override = env.MOLTBOT_CONFIG_PATH?.trim() || env.CLAWDBOT_CONFIG_PATH?.trim();
+ if (override) return resolveUserPath(override);
+ const stateOverride = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
+ const candidates = [
+ path.join(stateDir, CONFIG_FILENAME),
+ path.join(stateDir, LEGACY_CONFIG_FILENAME),
+ ];
+ const existing = candidates.find((candidate) => {
+ try {
+ return fs.existsSync(candidate);
+ } catch {
+ return false;
+ }
+ });
+ if (existing) return existing;
+ if (stateOverride) return path.join(stateDir, CONFIG_FILENAME);
+ const defaultStateDir = resolveStateDir(env, homedir);
+ if (path.resolve(stateDir) === path.resolve(defaultStateDir)) {
+ return resolveConfigPathCandidate(env, homedir);
+ }
+ return path.join(stateDir, CONFIG_FILENAME);
+}
+
+export const CONFIG_PATH = resolveConfigPathCandidate();
/**
* Resolve default config path candidates across new + legacy locations.
@@ -84,14 +151,18 @@ export function resolveDefaultConfigCandidates(
const moltbotStateDir = env.MOLTBOT_STATE_DIR?.trim();
if (moltbotStateDir) {
candidates.push(path.join(resolveUserPath(moltbotStateDir), CONFIG_FILENAME));
+ candidates.push(path.join(resolveUserPath(moltbotStateDir), LEGACY_CONFIG_FILENAME));
}
const legacyStateDirOverride = env.CLAWDBOT_STATE_DIR?.trim();
if (legacyStateDirOverride) {
candidates.push(path.join(resolveUserPath(legacyStateDirOverride), CONFIG_FILENAME));
+ candidates.push(path.join(resolveUserPath(legacyStateDirOverride), LEGACY_CONFIG_FILENAME));
}
candidates.push(path.join(newStateDir(homedir), CONFIG_FILENAME));
+ candidates.push(path.join(newStateDir(homedir), LEGACY_CONFIG_FILENAME));
candidates.push(path.join(legacyStateDir(homedir), CONFIG_FILENAME));
+ candidates.push(path.join(legacyStateDir(homedir), LEGACY_CONFIG_FILENAME));
return candidates;
}
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 26856dfd1..7deb13159 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -587,7 +587,7 @@ const FIELD_HELP: Record = {
"commands.restart": "Allow /restart and gateway restart tool actions (default: false).",
"commands.useAccessGroups": "Enforce access-group allowlists/policies for commands.",
"session.dmScope":
- 'DM session scoping: "main" keeps continuity; "per-peer" or "per-channel-peer" isolates DM history (recommended for shared inboxes).',
+ 'DM session scoping: "main" keeps continuity; "per-peer", "per-channel-peer", or "per-account-channel-peer" isolates DM history (recommended for shared inboxes/multi-account).',
"session.identityLinks":
"Map canonical identities to provider-prefixed peer IDs for DM session linking (example: telegram:123456).",
"channels.telegram.configWrites":
diff --git a/src/config/types.base.ts b/src/config/types.base.ts
index cc805e8ec..e7da1ecd8 100644
--- a/src/config/types.base.ts
+++ b/src/config/types.base.ts
@@ -3,7 +3,7 @@ import type { NormalizedChatType } from "../channels/chat-type.js";
export type ReplyMode = "text" | "command";
export type TypingMode = "never" | "instant" | "thinking" | "message";
export type SessionScope = "per-sender" | "global";
-export type DmScope = "main" | "per-peer" | "per-channel-peer";
+export type DmScope = "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
export type ReplyToMode = "off" | "first" | "all";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts
index b9e7b42cc..4412f5515 100644
--- a/src/config/zod-schema.session.ts
+++ b/src/config/zod-schema.session.ts
@@ -20,7 +20,12 @@ export const SessionSchema = z
.object({
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
dmScope: z
- .union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
+ .union([
+ z.literal("main"),
+ z.literal("per-peer"),
+ z.literal("per-channel-peer"),
+ z.literal("per-account-channel-peer"),
+ ])
.optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),
diff --git a/src/cron/cron-protocol-conformance.test.ts b/src/cron/cron-protocol-conformance.test.ts
index 3da74c874..3eebfa290 100644
--- a/src/cron/cron-protocol-conformance.test.ts
+++ b/src/cron/cron-protocol-conformance.test.ts
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
-import { MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
+import { LEGACY_MACOS_APP_SOURCES_DIR, MACOS_APP_SOURCES_DIR } from "../compat/legacy-names.js";
import { CronPayloadSchema } from "../gateway/protocol/schema.js";
type SchemaLike = {
@@ -30,7 +30,26 @@ function extractCronChannels(schema: SchemaLike): string[] {
const UI_FILES = ["ui/src/ui/types.ts", "ui/src/ui/ui-types.ts", "ui/src/ui/views/cron.ts"];
-const SWIFT_FILES = [`${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`];
+const SWIFT_FILE_CANDIDATES = [
+ `${MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
+ `${LEGACY_MACOS_APP_SOURCES_DIR}/GatewayConnection.swift`,
+];
+
+async function resolveSwiftFiles(cwd: string): Promise {
+ const matches: string[] = [];
+ for (const relPath of SWIFT_FILE_CANDIDATES) {
+ try {
+ await fs.access(path.join(cwd, relPath));
+ matches.push(relPath);
+ } catch {
+ // ignore missing path
+ }
+ }
+ if (matches.length === 0) {
+ throw new Error(`Missing Swift cron definition. Tried: ${SWIFT_FILE_CANDIDATES.join(", ")}`);
+ }
+ return matches;
+}
describe("cron protocol conformance", () => {
it("ui + swift include all cron providers from gateway schema", async () => {
@@ -45,7 +64,8 @@ describe("cron protocol conformance", () => {
}
}
- for (const relPath of SWIFT_FILES) {
+ const swiftFiles = await resolveSwiftFiles(cwd);
+ for (const relPath of swiftFiles) {
const content = await fs.readFile(path.join(cwd, relPath), "utf-8");
for (const channel of channels) {
const pattern = new RegExp(`\\bcase\\s+${channel}\\b`);
@@ -61,7 +81,8 @@ describe("cron protocol conformance", () => {
expect(uiTypes.includes("jobs:")).toBe(true);
expect(uiTypes.includes("jobCount")).toBe(false);
- const swiftPath = path.join(cwd, SWIFT_FILES[0]);
+ const [swiftRelPath] = await resolveSwiftFiles(cwd);
+ const swiftPath = path.join(cwd, swiftRelPath);
const swift = await fs.readFile(swiftPath, "utf-8");
expect(swift.includes("struct CronSchedulerStatus")).toBe(true);
expect(swift.includes("let jobs:")).toBe(true);
diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts
index a47d0f4f1..22b402ae3 100644
--- a/src/discord/send.outbound.ts
+++ b/src/discord/send.outbound.ts
@@ -13,7 +13,7 @@ import {
createDiscordClient,
normalizeDiscordPollInput,
normalizeStickerIds,
- parseRecipient,
+ parseAndResolveRecipient,
resolveChannelId,
sendDiscordMedia,
sendDiscordText,
@@ -49,7 +49,7 @@ export async function sendMessageDiscord(
const chunkMode = resolveChunkMode(cfg, "discord", accountInfo.accountId);
const textWithTables = convertMarkdownTables(text ?? "", tableMode);
const { token, rest, request } = createDiscordClient(opts, cfg);
- const recipient = parseRecipient(to);
+ const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
try {
@@ -104,7 +104,7 @@ export async function sendStickerDiscord(
): Promise {
const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg);
- const recipient = parseRecipient(to);
+ const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim();
const stickers = normalizeStickerIds(stickerIds);
@@ -131,7 +131,7 @@ export async function sendPollDiscord(
): Promise {
const cfg = loadConfig();
const { rest, request } = createDiscordClient(opts, cfg);
- const recipient = parseRecipient(to);
+ const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
const content = opts.content?.trim();
const payload = normalizeDiscordPollInput(poll);
diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts
index 4919be29d..e247300ee 100644
--- a/src/discord/send.shared.ts
+++ b/src/discord/send.shared.ts
@@ -13,7 +13,7 @@ import type { ChunkMode } from "../auto-reply/chunk.js";
import { chunkDiscordTextWithMode } from "./chunk.js";
import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js";
import { DiscordSendError } from "./send.types.js";
-import { parseDiscordTarget } from "./targets.js";
+import { parseDiscordTarget, resolveDiscordTarget } from "./targets.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000;
@@ -101,6 +101,51 @@ function parseRecipient(raw: string): DiscordRecipient {
return { kind: target.kind, id: target.id };
}
+/**
+ * Parse and resolve Discord recipient, including username lookup.
+ * This enables sending DMs by username (e.g., "john.doe") by querying
+ * the Discord directory to resolve usernames to user IDs.
+ *
+ * @param raw - The recipient string (username, ID, or known format)
+ * @param accountId - Discord account ID to use for directory lookup
+ * @returns Parsed DiscordRecipient with resolved user ID if applicable
+ */
+export async function parseAndResolveRecipient(
+ raw: string,
+ accountId?: string,
+): Promise {
+ const cfg = loadConfig();
+ const accountInfo = resolveDiscordAccount({ cfg, accountId });
+
+ // First try to resolve using directory lookup (handles usernames)
+ const trimmed = raw.trim();
+ const parseOptions = {
+ ambiguousMessage: `Ambiguous Discord recipient "${trimmed}". Use "user:${trimmed}" for DMs or "channel:${trimmed}" for channel messages.`,
+ };
+
+ const resolved = await resolveDiscordTarget(
+ raw,
+ {
+ cfg,
+ accountId: accountInfo.accountId,
+ },
+ parseOptions,
+ );
+
+ if (resolved) {
+ return { kind: resolved.kind, id: resolved.id };
+ }
+
+ // Fallback to standard parsing (for channels, etc.)
+ const parsed = parseDiscordTarget(raw, parseOptions);
+
+ if (!parsed) {
+ throw new Error("Recipient is required for Discord sends");
+ }
+
+ return { kind: parsed.kind, id: parsed.id };
+}
+
function normalizeStickerIds(raw: string[]) {
const ids = raw.map((entry) => entry.trim()).filter(Boolean);
if (ids.length === 0) {
diff --git a/src/discord/targets.test.ts b/src/discord/targets.test.ts
index 3eee1eb1e..7ac39450b 100644
--- a/src/discord/targets.test.ts
+++ b/src/discord/targets.test.ts
@@ -1,7 +1,13 @@
-import { describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ClawdbotConfig } from "../config/config.js";
import { normalizeDiscordMessagingTarget } from "../channels/plugins/normalize/discord.js";
-import { parseDiscordTarget, resolveDiscordChannelId } from "./targets.js";
+import { listDiscordDirectoryPeersLive } from "./directory-live.js";
+import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
+
+vi.mock("./directory-live.js", () => ({
+ listDiscordDirectoryPeersLive: vi.fn(),
+}));
describe("parseDiscordTarget", () => {
it("parses user mention and prefixes", () => {
@@ -68,6 +74,38 @@ describe("resolveDiscordChannelId", () => {
});
});
+describe("resolveDiscordTarget", () => {
+ const cfg = { channels: { discord: {} } } as ClawdbotConfig;
+ const listPeers = vi.mocked(listDiscordDirectoryPeersLive);
+
+ beforeEach(() => {
+ listPeers.mockReset();
+ });
+
+ it("returns a resolved user for usernames", async () => {
+ listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]);
+
+ await expect(
+ resolveDiscordTarget("jane", { cfg, accountId: "default" }),
+ ).resolves.toMatchObject({ kind: "user", id: "999", normalized: "user:999" });
+ });
+
+ it("falls back to parsing when lookup misses", async () => {
+ listPeers.mockResolvedValueOnce([]);
+ await expect(
+ resolveDiscordTarget("general", { cfg, accountId: "default" }),
+ ).resolves.toMatchObject({ kind: "channel", id: "general" });
+ });
+
+ it("does not call directory lookup for explicit user ids", async () => {
+ listPeers.mockResolvedValueOnce([]);
+ await expect(
+ resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
+ ).resolves.toMatchObject({ kind: "user", id: "123" });
+ expect(listPeers).not.toHaveBeenCalled();
+ });
+});
+
describe("normalizeDiscordMessagingTarget", () => {
it("defaults raw numeric ids to channels", () => {
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
diff --git a/src/discord/targets.ts b/src/discord/targets.ts
index 3a3c93ec8..5ea6f5b1b 100644
--- a/src/discord/targets.ts
+++ b/src/discord/targets.ts
@@ -7,6 +7,10 @@ import {
type MessagingTargetParseOptions,
} from "../channels/targets.js";
+import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
+
+import { listDiscordDirectoryPeersLive } from "./directory-live.js";
+
export type DiscordTargetKind = MessagingTargetKind;
export type DiscordTarget = MessagingTarget;
@@ -60,3 +64,93 @@ export function resolveDiscordChannelId(raw: string): string {
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
return requireTargetKind({ platform: "Discord", target, kind: "channel" });
}
+
+/**
+ * Resolve a Discord username to user ID using the directory lookup.
+ * This enables sending DMs by username instead of requiring explicit user IDs.
+ *
+ * @param raw - The username or raw target string (e.g., "john.doe")
+ * @param options - Directory configuration params (cfg, accountId, limit)
+ * @param parseOptions - Messaging target parsing options (defaults, ambiguity message)
+ * @returns Parsed MessagingTarget with user ID, or undefined if not found
+ */
+export async function resolveDiscordTarget(
+ raw: string,
+ options: DirectoryConfigParams,
+ parseOptions: DiscordTargetParseOptions = {},
+): Promise {
+ const trimmed = raw.trim();
+ if (!trimmed) return undefined;
+
+ const likelyUsername = isLikelyUsername(trimmed);
+ const shouldLookup = isExplicitUserLookup(trimmed, parseOptions) || likelyUsername;
+ const directParse = safeParseDiscordTarget(trimmed, parseOptions);
+ if (directParse && directParse.kind !== "channel" && !likelyUsername) {
+ return directParse;
+ }
+ if (!shouldLookup) {
+ return directParse ?? parseDiscordTarget(trimmed, parseOptions);
+ }
+
+ // Try to resolve as a username via directory lookup
+ try {
+ const directoryEntries = await listDiscordDirectoryPeersLive({
+ ...options,
+ query: trimmed,
+ limit: 1,
+ });
+
+ const match = directoryEntries[0];
+ if (match && match.kind === "user") {
+ // Extract user ID from the directory entry (format: "user:")
+ const userId = match.id.replace(/^user:/, "");
+ return buildMessagingTarget("user", userId, trimmed);
+ }
+ } catch {
+ // Directory lookup failed - fall through to parse as-is
+ // This preserves existing behavior for channel names
+ }
+
+ // Fallback to original parsing (for channels, etc.)
+ return parseDiscordTarget(trimmed, parseOptions);
+}
+
+function safeParseDiscordTarget(
+ input: string,
+ options: DiscordTargetParseOptions,
+): MessagingTarget | undefined {
+ try {
+ return parseDiscordTarget(input, options);
+ } catch {
+ return undefined;
+ }
+}
+
+function isExplicitUserLookup(input: string, options: DiscordTargetParseOptions): boolean {
+ if (/^<@!?(\d+)>$/.test(input)) {
+ return true;
+ }
+ if (/^(user:|discord:)/.test(input)) {
+ return true;
+ }
+ if (input.startsWith("@")) {
+ return true;
+ }
+ if (/^\d+$/.test(input)) {
+ return options.defaultKind === "user";
+ }
+ return false;
+}
+
+/**
+ * Check if a string looks like a Discord username (not a mention, prefix, or ID).
+ * Usernames typically don't start with special characters except underscore.
+ */
+function isLikelyUsername(input: string): boolean {
+ // Skip if it's already a known format
+ if (/^(user:|channel:|discord:|@|<@!?)|[\d]+$/.test(input)) {
+ return false;
+ }
+ // Likely a username if it doesn't match known patterns
+ return true;
+}
diff --git a/src/hooks/bundled/session-memory/HOOK.md b/src/hooks/bundled/session-memory/HOOK.md
index 2a635a645..41223eb05 100644
--- a/src/hooks/bundled/session-memory/HOOK.md
+++ b/src/hooks/bundled/session-memory/HOOK.md
@@ -23,7 +23,7 @@ Automatically saves session context to your workspace memory when you issue the
When you run `/new` to start a fresh session:
1. **Finds the previous session** - Uses the pre-reset session entry to locate the correct transcript
-2. **Extracts conversation** - Reads the last 15 lines of conversation from the session
+2. **Extracts conversation** - Reads the last N user/assistant messages from the session (default: 15, configurable)
3. **Generates descriptive slug** - Uses LLM to create a meaningful filename slug based on conversation content
4. **Saves to memory** - Creates a new file at `/memory/YYYY-MM-DD-slug.md`
5. **Sends confirmation** - Notifies you with the file path
@@ -57,7 +57,30 @@ The hook uses your configured LLM provider to generate slugs, so it works with a
## Configuration
-No additional configuration required. The hook automatically:
+The hook supports optional configuration:
+
+| Option | Type | Default | Description |
+| ---------- | ------ | ------- | --------------------------------------------------------------- |
+| `messages` | number | 15 | Number of user/assistant messages to include in the memory file |
+
+Example configuration:
+
+```json
+{
+ "hooks": {
+ "internal": {
+ "entries": {
+ "session-memory": {
+ "enabled": true,
+ "messages": 25
+ }
+ }
+ }
+ }
+}
+```
+
+The hook automatically:
- Uses your workspace directory (`~/clawd` by default)
- Uses your configured LLM for slug generation
diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts
new file mode 100644
index 000000000..525e21059
--- /dev/null
+++ b/src/hooks/bundled/session-memory/handler.test.ts
@@ -0,0 +1,379 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+import handler from "./handler.js";
+import { createHookEvent } from "../../hooks.js";
+import type { ClawdbotConfig } from "../../../config/config.js";
+import { makeTempWorkspace, writeWorkspaceFile } from "../../../test-helpers/workspace.js";
+
+/**
+ * Create a mock session JSONL file with various entry types
+ */
+function createMockSessionContent(
+ entries: Array<{ role: string; content: string } | { type: string }>,
+): string {
+ return entries
+ .map((entry) => {
+ if ("role" in entry) {
+ return JSON.stringify({
+ type: "message",
+ message: {
+ role: entry.role,
+ content: entry.content,
+ },
+ });
+ }
+ // Non-message entry (tool call, system, etc.)
+ return JSON.stringify(entry);
+ })
+ .join("\n");
+}
+
+describe("session-memory hook", () => {
+ it("skips non-command events", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+
+ const event = createHookEvent("agent", "bootstrap", "agent:main:main", {
+ workspaceDir: tempDir,
+ });
+
+ await handler(event);
+
+ // Memory directory should not be created for non-command events
+ const memoryDir = path.join(tempDir, "memory");
+ await expect(fs.access(memoryDir)).rejects.toThrow();
+ });
+
+ it("skips commands other than new", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+
+ const event = createHookEvent("command", "help", "agent:main:main", {
+ workspaceDir: tempDir,
+ });
+
+ await handler(event);
+
+ // Memory directory should not be created for other commands
+ const memoryDir = path.join(tempDir, "memory");
+ await expect(fs.access(memoryDir)).rejects.toThrow();
+ });
+
+ it("creates memory file with session content on /new command", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ // Create a mock session file with user/assistant messages
+ const sessionContent = createMockSessionContent([
+ { role: "user", content: "Hello there" },
+ { role: "assistant", content: "Hi! How can I help?" },
+ { role: "user", content: "What is 2+2?" },
+ { role: "assistant", content: "2+2 equals 4" },
+ ]);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ // Memory file should be created
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ expect(files.length).toBe(1);
+
+ // Read the memory file and verify content
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+ expect(memoryContent).toContain("user: Hello there");
+ expect(memoryContent).toContain("assistant: Hi! How can I help?");
+ expect(memoryContent).toContain("user: What is 2+2?");
+ expect(memoryContent).toContain("assistant: 2+2 equals 4");
+ });
+
+ it("filters out non-message entries (tool calls, system)", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ // Create session with mixed entry types
+ const sessionContent = createMockSessionContent([
+ { role: "user", content: "Hello" },
+ { type: "tool_use", tool: "search", input: "test" },
+ { role: "assistant", content: "World" },
+ { type: "tool_result", result: "found it" },
+ { role: "user", content: "Thanks" },
+ ]);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+
+ // Only user/assistant messages should be present
+ expect(memoryContent).toContain("user: Hello");
+ expect(memoryContent).toContain("assistant: World");
+ expect(memoryContent).toContain("user: Thanks");
+ // Tool entries should not appear
+ expect(memoryContent).not.toContain("tool_use");
+ expect(memoryContent).not.toContain("tool_result");
+ expect(memoryContent).not.toContain("search");
+ });
+
+ it("filters out command messages starting with /", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ const sessionContent = createMockSessionContent([
+ { role: "user", content: "/help" },
+ { role: "assistant", content: "Here is help info" },
+ { role: "user", content: "Normal message" },
+ { role: "user", content: "/new" },
+ ]);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+
+ // Command messages should be filtered out
+ expect(memoryContent).not.toContain("/help");
+ expect(memoryContent).not.toContain("/new");
+ // Normal messages should be present
+ expect(memoryContent).toContain("assistant: Here is help info");
+ expect(memoryContent).toContain("user: Normal message");
+ });
+
+ it("respects custom messages config (limits to N messages)", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ // Create 10 messages
+ const entries = [];
+ for (let i = 1; i <= 10; i++) {
+ entries.push({ role: "user", content: `Message ${i}` });
+ }
+ const sessionContent = createMockSessionContent(entries);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ // Configure to only include last 3 messages
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ hooks: {
+ internal: {
+ entries: {
+ "session-memory": { enabled: true, messages: 3 },
+ },
+ },
+ },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+
+ // Only last 3 messages should be present
+ expect(memoryContent).not.toContain("user: Message 1\n");
+ expect(memoryContent).not.toContain("user: Message 7\n");
+ expect(memoryContent).toContain("user: Message 8");
+ expect(memoryContent).toContain("user: Message 9");
+ expect(memoryContent).toContain("user: Message 10");
+ });
+
+ it("filters messages before slicing (fix for #2681)", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ // Create session with many tool entries interspersed with messages
+ // This tests that we filter FIRST, then slice - not the other way around
+ const entries = [
+ { role: "user", content: "First message" },
+ { type: "tool_use", tool: "test1" },
+ { type: "tool_result", result: "result1" },
+ { role: "assistant", content: "Second message" },
+ { type: "tool_use", tool: "test2" },
+ { type: "tool_result", result: "result2" },
+ { role: "user", content: "Third message" },
+ { type: "tool_use", tool: "test3" },
+ { type: "tool_result", result: "result3" },
+ { role: "assistant", content: "Fourth message" },
+ ];
+ const sessionContent = createMockSessionContent(entries);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ // Request 3 messages - if we sliced first, we'd only get 1-2 messages
+ // because the last 3 lines include tool entries
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ hooks: {
+ internal: {
+ entries: {
+ "session-memory": { enabled: true, messages: 3 },
+ },
+ },
+ },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+
+ // Should have exactly 3 user/assistant messages (the last 3)
+ expect(memoryContent).not.toContain("First message");
+ expect(memoryContent).toContain("user: Third message");
+ expect(memoryContent).toContain("assistant: Second message");
+ expect(memoryContent).toContain("assistant: Fourth message");
+ });
+
+ it("handles empty session files gracefully", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: "",
+ });
+
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ // Should not throw
+ await handler(event);
+
+ // Memory file should still be created with metadata
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ expect(files.length).toBe(1);
+ });
+
+ it("handles session files with fewer messages than requested", async () => {
+ const tempDir = await makeTempWorkspace("clawdbot-session-memory-");
+ const sessionsDir = path.join(tempDir, "sessions");
+ await fs.mkdir(sessionsDir, { recursive: true });
+
+ // Only 2 messages but requesting 15 (default)
+ const sessionContent = createMockSessionContent([
+ { role: "user", content: "Only message 1" },
+ { role: "assistant", content: "Only message 2" },
+ ]);
+ const sessionFile = await writeWorkspaceFile({
+ dir: sessionsDir,
+ name: "test-session.jsonl",
+ content: sessionContent,
+ });
+
+ const cfg: ClawdbotConfig = {
+ agents: { defaults: { workspace: tempDir } },
+ };
+
+ const event = createHookEvent("command", "new", "agent:main:main", {
+ cfg,
+ previousSessionEntry: {
+ sessionId: "test-123",
+ sessionFile,
+ },
+ });
+
+ await handler(event);
+
+ const memoryDir = path.join(tempDir, "memory");
+ const files = await fs.readdir(memoryDir);
+ const memoryContent = await fs.readFile(path.join(memoryDir, files[0]!), "utf-8");
+
+ // Both messages should be included
+ expect(memoryContent).toContain("user: Only message 1");
+ expect(memoryContent).toContain("assistant: Only message 2");
+ });
+});
diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts
index c087d73e8..5b5a69c9c 100644
--- a/src/hooks/bundled/session-memory/handler.ts
+++ b/src/hooks/bundled/session-memory/handler.ts
@@ -8,25 +8,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
+import { fileURLToPath } from "node:url";
import type { MoltbotConfig } from "../../../config/config.js";
import { resolveAgentWorkspaceDir } from "../../../agents/agent-scope.js";
import { resolveAgentIdFromSessionKey } from "../../../routing/session-key.js";
+import { resolveHookConfig } from "../../config.js";
import type { HookHandler } from "../../hooks.js";
/**
* Read recent messages from session file for slug generation
*/
-async function getRecentSessionContent(sessionFilePath: string): Promise {
+async function getRecentSessionContent(
+ sessionFilePath: string,
+ messageCount: number = 15,
+): Promise {
try {
const content = await fs.readFile(sessionFilePath, "utf-8");
const lines = content.trim().split("\n");
- // Get last 15 lines (recent conversation)
- const recentLines = lines.slice(-15);
-
- // Parse JSONL and extract messages
- const messages: string[] = [];
- for (const line of recentLines) {
+ // Parse JSONL and extract user/assistant messages first
+ const allMessages: string[] = [];
+ for (const line of lines) {
try {
const entry = JSON.parse(line);
// Session files have entries with type="message" containing a nested message object
@@ -39,7 +41,7 @@ async function getRecentSessionContent(sessionFilePath: string): Promise c.type === "text")?.text
: msg.content;
if (text && !text.startsWith("/")) {
- messages.push(`${role}: ${text}`);
+ allMessages.push(`${role}: ${text}`);
}
}
}
@@ -48,7 +50,9 @@ async function getRecentSessionContent(sessionFilePath: string): Promise {
const sessionFile = currentSessionFile || undefined;
+ // Read message count from hook config (default: 15)
+ const hookConfig = resolveHookConfig(cfg, "session-memory");
+ const messageCount =
+ typeof hookConfig?.messages === "number" && hookConfig.messages > 0
+ ? hookConfig.messages
+ : 15;
+
let slug: string | null = null;
let sessionContent: string | null = null;
if (sessionFile) {
// Get recent conversation content
- sessionContent = await getRecentSessionContent(sessionFile);
+ sessionContent = await getRecentSessionContent(sessionFile, messageCount);
console.log("[session-memory] sessionContent length:", sessionContent?.length || 0);
if (sessionContent && cfg) {
@@ -106,10 +117,7 @@ const saveSessionToMemory: HookHandler = async (event) => {
// Dynamically import the LLM slug generator (avoids module caching issues)
// When compiled, handler is at dist/hooks/bundled/session-memory/handler.js
// Going up ../.. puts us at dist/hooks/, so just add llm-slug-generator.js
- const moltbotRoot = path.resolve(
- path.dirname(import.meta.url.replace("file://", "")),
- "../..",
- );
+ const moltbotRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const slugGenPath = path.join(moltbotRoot, "llm-slug-generator.js");
const { generateSlugViaLLM } = await import(slugGenPath);
diff --git a/src/infra/gateway-lock.ts b/src/infra/gateway-lock.ts
index a3c4d1290..aa65e7d81 100644
--- a/src/infra/gateway-lock.ts
+++ b/src/infra/gateway-lock.ts
@@ -72,6 +72,7 @@ function isGatewayArgv(args: string[]): boolean {
"dist/index.js",
"dist/index.mjs",
"dist/entry.js",
+ "moltbot.mjs",
"dist/entry.mjs",
"scripts/run-node.mjs",
"src/index.ts",
diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts
index c74abc509..9c12fab96 100644
--- a/src/infra/outbound/outbound-session.ts
+++ b/src/infra/outbound/outbound-session.ts
@@ -103,11 +103,13 @@ function buildBaseSessionKey(params: {
cfg: MoltbotConfig;
agentId: string;
channel: ChannelId;
+ accountId?: string | null;
peer: RoutePeer;
}): string {
return buildAgentSessionKey({
agentId: params.agentId,
channel: params.channel,
+ accountId: params.accountId,
peer: params.peer,
dmScope: params.cfg.session?.dmScope ?? "main",
identityLinks: params.cfg.session?.identityLinks,
@@ -200,6 +202,7 @@ async function resolveSlackSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "slack",
+ accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.threadId ?? params.replyToId);
@@ -237,6 +240,7 @@ function resolveDiscordSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "discord",
+ accountId: params.accountId,
peer,
});
const explicitThreadId = normalizeThreadId(params.threadId);
@@ -285,6 +289,7 @@ function resolveTelegramSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "telegram",
+ accountId: params.accountId,
peer,
});
return {
@@ -312,6 +317,7 @@ function resolveWhatsAppSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "whatsapp",
+ accountId: params.accountId,
peer,
});
return {
@@ -337,6 +343,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
+ accountId: params.accountId,
peer,
});
return {
@@ -371,6 +378,7 @@ function resolveSignalSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "signal",
+ accountId: params.accountId,
peer,
});
return {
@@ -395,6 +403,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
+ accountId: params.accountId,
peer,
});
return {
@@ -419,6 +428,7 @@ function resolveIMessageSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "imessage",
+ accountId: params.accountId,
peer,
});
const toPrefix =
@@ -450,6 +460,7 @@ function resolveMatrixSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "matrix",
+ accountId: params.accountId,
peer,
});
return {
@@ -483,6 +494,7 @@ function resolveMSTeamsSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "msteams",
+ accountId: params.accountId,
peer,
});
return {
@@ -517,6 +529,7 @@ function resolveMattermostSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "mattermost",
+ accountId: params.accountId,
peer,
});
const threadId = normalizeThreadId(params.replyToId ?? params.threadId);
@@ -561,6 +574,7 @@ function resolveBlueBubblesSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "bluebubbles",
+ accountId: params.accountId,
peer,
});
return {
@@ -586,6 +600,7 @@ function resolveNextcloudTalkSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nextcloud-talk",
+ accountId: params.accountId,
peer,
});
return {
@@ -612,6 +627,7 @@ function resolveZaloSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalo",
+ accountId: params.accountId,
peer,
});
return {
@@ -639,6 +655,7 @@ function resolveZalouserSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "zalouser",
+ accountId: params.accountId,
peer,
});
return {
@@ -661,6 +678,7 @@ function resolveNostrSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "nostr",
+ accountId: params.accountId,
peer,
});
return {
@@ -719,6 +737,7 @@ function resolveTlonSession(
cfg: params.cfg,
agentId: params.agentId,
channel: "tlon",
+ accountId: params.accountId,
peer,
});
return {
diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts
index cb3d5f333..f5e50740e 100644
--- a/src/infra/state-migrations.ts
+++ b/src/infra/state-migrations.ts
@@ -4,7 +4,12 @@ import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { MoltbotConfig } from "../config/config.js";
-import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
+import {
+ resolveLegacyStateDir,
+ resolveNewStateDir,
+ resolveOAuthDir,
+ resolveStateDir,
+} from "../config/paths.js";
import type { SessionEntry } from "../config/sessions.js";
import type { SessionScope } from "../config/sessions/types.js";
import { saveSessionStore } from "../config/sessions.js";
@@ -59,6 +64,7 @@ type MigrationLogger = {
};
let autoMigrateChecked = false;
+let autoMigrateStateDirChecked = false;
function isSurfaceGroupKey(key: string): boolean {
return key.includes(":group:") || key.includes(":channel:");
@@ -267,6 +273,131 @@ export function resetAutoMigrateLegacyAgentDirForTest() {
resetAutoMigrateLegacyStateForTest();
}
+export function resetAutoMigrateLegacyStateDirForTest() {
+ autoMigrateStateDirChecked = false;
+}
+
+type StateDirMigrationResult = {
+ migrated: boolean;
+ skipped: boolean;
+ changes: string[];
+ warnings: string[];
+};
+
+function resolveSymlinkTarget(linkPath: string): string | null {
+ try {
+ const target = fs.readlinkSync(linkPath);
+ return path.resolve(path.dirname(linkPath), target);
+ } catch {
+ return null;
+ }
+}
+
+function formatStateDirMigration(legacyDir: string, targetDir: string): string {
+ return `State dir: ${legacyDir} → ${targetDir} (legacy path now symlinked)`;
+}
+
+function isDirPath(filePath: string): boolean {
+ try {
+ return fs.statSync(filePath).isDirectory();
+ } catch {
+ return false;
+ }
+}
+
+export async function autoMigrateLegacyStateDir(params: {
+ env?: NodeJS.ProcessEnv;
+ homedir?: () => string;
+ log?: MigrationLogger;
+}): Promise {
+ if (autoMigrateStateDirChecked) {
+ return { migrated: false, skipped: true, changes: [], warnings: [] };
+ }
+ autoMigrateStateDirChecked = true;
+
+ const env = params.env ?? process.env;
+ if (env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim()) {
+ return { migrated: false, skipped: true, changes: [], warnings: [] };
+ }
+
+ const homedir = params.homedir ?? os.homedir;
+ const legacyDir = resolveLegacyStateDir(homedir);
+ const targetDir = resolveNewStateDir(homedir);
+ const warnings: string[] = [];
+ const changes: string[] = [];
+
+ let legacyStat: fs.Stats | null = null;
+ try {
+ legacyStat = fs.lstatSync(legacyDir);
+ } catch {
+ legacyStat = null;
+ }
+ if (!legacyStat) {
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+ if (!legacyStat.isDirectory() && !legacyStat.isSymbolicLink()) {
+ warnings.push(`Legacy state path is not a directory: ${legacyDir}`);
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+
+ if (legacyStat.isSymbolicLink()) {
+ const legacyTarget = resolveSymlinkTarget(legacyDir);
+ if (legacyTarget && path.resolve(legacyTarget) === path.resolve(targetDir)) {
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+ warnings.push(
+ `Legacy state dir is a symlink (${legacyDir} → ${legacyTarget ?? "unknown"}); skipping auto-migration.`,
+ );
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+
+ if (isDirPath(targetDir)) {
+ warnings.push(
+ `State dir migration skipped: target already exists (${targetDir}). Remove or merge manually.`,
+ );
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+
+ try {
+ fs.renameSync(legacyDir, targetDir);
+ } catch (err) {
+ warnings.push(`Failed to move legacy state dir (${legacyDir} → ${targetDir}): ${String(err)}`);
+ return { migrated: false, skipped: false, changes, warnings };
+ }
+
+ try {
+ fs.symlinkSync(targetDir, legacyDir, "dir");
+ changes.push(formatStateDirMigration(legacyDir, targetDir));
+ } catch (err) {
+ try {
+ if (process.platform === "win32") {
+ fs.symlinkSync(targetDir, legacyDir, "junction");
+ changes.push(formatStateDirMigration(legacyDir, targetDir));
+ } else {
+ throw err;
+ }
+ } catch (fallbackErr) {
+ try {
+ fs.renameSync(targetDir, legacyDir);
+ warnings.push(
+ `State dir migration rolled back (failed to link legacy path): ${String(fallbackErr)}`,
+ );
+ return { migrated: false, skipped: false, changes: [], warnings };
+ } catch (rollbackErr) {
+ warnings.push(
+ `State dir moved but failed to link legacy path (${legacyDir} → ${targetDir}): ${String(fallbackErr)}`,
+ );
+ warnings.push(
+ `Rollback failed; set MOLTBOT_STATE_DIR=${targetDir} to avoid split state: ${String(rollbackErr)}`,
+ );
+ changes.push(`State dir: ${legacyDir} → ${targetDir}`);
+ }
+ }
+ }
+
+ return { migrated: changes.length > 0, skipped: false, changes, warnings };
+}
+
export async function detectLegacyStateMigrations(params: {
cfg: MoltbotConfig;
env?: NodeJS.ProcessEnv;
@@ -591,8 +722,18 @@ export async function autoMigrateLegacyState(params: {
autoMigrateChecked = true;
const env = params.env ?? process.env;
+ const stateDirResult = await autoMigrateLegacyStateDir({
+ env,
+ homedir: params.homedir,
+ log: params.log,
+ });
if (env.CLAWDBOT_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim()) {
- return { migrated: false, skipped: true, changes: [], warnings: [] };
+ return {
+ migrated: stateDirResult.migrated,
+ skipped: true,
+ changes: stateDirResult.changes,
+ warnings: stateDirResult.warnings,
+ };
}
const detected = await detectLegacyStateMigrations({
@@ -601,14 +742,19 @@ export async function autoMigrateLegacyState(params: {
homedir: params.homedir,
});
if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) {
- return { migrated: false, skipped: false, changes: [], warnings: [] };
+ return {
+ migrated: stateDirResult.migrated,
+ skipped: false,
+ changes: stateDirResult.changes,
+ warnings: stateDirResult.warnings,
+ };
}
const now = params.now ?? (() => Date.now());
const sessions = await migrateLegacySessions(detected, now);
const agentDir = await migrateLegacyAgentDir(detected, now);
- const changes = [...sessions.changes, ...agentDir.changes];
- const warnings = [...sessions.warnings, ...agentDir.warnings];
+ const changes = [...stateDirResult.changes, ...sessions.changes, ...agentDir.changes];
+ const warnings = [...stateDirResult.warnings, ...sessions.warnings, ...agentDir.warnings];
const logger = params.log ?? createSubsystemLogger("state-migrations");
if (changes.length > 0) {
diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts
new file mode 100644
index 000000000..7944a1e73
--- /dev/null
+++ b/src/infra/unhandled-rejections.fatal-detection.test.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
+import process from "node:process";
+
+import { installUnhandledRejectionHandler } from "./unhandled-rejections.js";
+
+describe("installUnhandledRejectionHandler - fatal detection", () => {
+ let exitCalls: Array = [];
+ let consoleErrorSpy: ReturnType;
+ let consoleWarnSpy: ReturnType;
+ let originalExit: typeof process.exit;
+
+ beforeAll(() => {
+ originalExit = process.exit.bind(process);
+ installUnhandledRejectionHandler();
+ });
+
+ beforeEach(() => {
+ exitCalls = [];
+
+ vi.spyOn(process, "exit").mockImplementation((code: string | number | null | undefined) => {
+ if (code !== undefined && code !== null) {
+ exitCalls.push(code);
+ }
+ });
+
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ consoleErrorSpy.mockRestore();
+ consoleWarnSpy.mockRestore();
+ });
+
+ afterAll(() => {
+ process.exit = originalExit;
+ });
+
+ describe("fatal errors", () => {
+ it("exits on ERR_OUT_OF_MEMORY", () => {
+ const oomErr = Object.assign(new Error("Out of memory"), {
+ code: "ERR_OUT_OF_MEMORY",
+ });
+
+ process.emit("unhandledRejection", oomErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "[moltbot] FATAL unhandled rejection:",
+ expect.stringContaining("Out of memory"),
+ );
+ });
+
+ it("exits on ERR_SCRIPT_EXECUTION_TIMEOUT", () => {
+ const timeoutErr = Object.assign(new Error("Script execution timeout"), {
+ code: "ERR_SCRIPT_EXECUTION_TIMEOUT",
+ });
+
+ process.emit("unhandledRejection", timeoutErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ });
+
+ it("exits on ERR_WORKER_OUT_OF_MEMORY", () => {
+ const workerOomErr = Object.assign(new Error("Worker out of memory"), {
+ code: "ERR_WORKER_OUT_OF_MEMORY",
+ });
+
+ process.emit("unhandledRejection", workerOomErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ });
+ });
+
+ describe("configuration errors", () => {
+ it("exits on INVALID_CONFIG", () => {
+ const configErr = Object.assign(new Error("Invalid config"), {
+ code: "INVALID_CONFIG",
+ });
+
+ process.emit("unhandledRejection", configErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "[moltbot] CONFIGURATION ERROR - requires fix:",
+ expect.stringContaining("Invalid config"),
+ );
+ });
+
+ it("exits on MISSING_API_KEY", () => {
+ const missingKeyErr = Object.assign(new Error("Missing API key"), {
+ code: "MISSING_API_KEY",
+ });
+
+ process.emit("unhandledRejection", missingKeyErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ });
+ });
+
+ describe("non-fatal errors", () => {
+ it("does NOT exit on undici fetch failures", () => {
+ const fetchErr = Object.assign(new TypeError("fetch failed"), {
+ cause: { code: "UND_ERR_CONNECT_TIMEOUT", syscall: "connect" },
+ });
+
+ process.emit("unhandledRejection", fetchErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([]);
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ "[moltbot] Non-fatal unhandled rejection (continuing):",
+ expect.stringContaining("fetch failed"),
+ );
+ });
+
+ it("does NOT exit on DNS resolution failures", () => {
+ const dnsErr = Object.assign(new Error("DNS resolve failed"), {
+ code: "UND_ERR_DNS_RESOLVE_FAILED",
+ });
+
+ process.emit("unhandledRejection", dnsErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([]);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ });
+
+ it("exits on generic errors without code", () => {
+ const genericErr = new Error("Something went wrong");
+
+ process.emit("unhandledRejection", genericErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([1]);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ "[moltbot] Unhandled promise rejection:",
+ expect.stringContaining("Something went wrong"),
+ );
+ });
+
+ it("does NOT exit on connection reset errors", () => {
+ const connResetErr = Object.assign(new Error("Connection reset"), {
+ code: "ECONNRESET",
+ });
+
+ process.emit("unhandledRejection", connResetErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([]);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ });
+
+ it("does NOT exit on timeout errors", () => {
+ const timeoutErr = Object.assign(new Error("Timeout"), {
+ code: "ETIMEDOUT",
+ });
+
+ process.emit("unhandledRejection", timeoutErr, Promise.resolve());
+
+ expect(exitCalls).toEqual([]);
+ expect(consoleWarnSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts
index 108b6c016..d186c6a78 100644
--- a/src/infra/unhandled-rejections.ts
+++ b/src/infra/unhandled-rejections.ts
@@ -1,11 +1,52 @@
import process from "node:process";
-import { formatUncaughtError } from "./errors.js";
+import { extractErrorCode, formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set();
+const FATAL_ERROR_CODES = new Set([
+ "ERR_OUT_OF_MEMORY",
+ "ERR_SCRIPT_EXECUTION_TIMEOUT",
+ "ERR_WORKER_OUT_OF_MEMORY",
+ "ERR_WORKER_UNCAUGHT_EXCEPTION",
+ "ERR_WORKER_INITIALIZATION_FAILED",
+]);
+
+const CONFIG_ERROR_CODES = new Set(["INVALID_CONFIG", "MISSING_API_KEY", "MISSING_CREDENTIALS"]);
+
+// Network error codes that indicate transient failures (shouldn't crash the gateway)
+const TRANSIENT_NETWORK_CODES = new Set([
+ "ECONNRESET",
+ "ECONNREFUSED",
+ "ENOTFOUND",
+ "ETIMEDOUT",
+ "ESOCKETTIMEDOUT",
+ "ECONNABORTED",
+ "EPIPE",
+ "EHOSTUNREACH",
+ "ENETUNREACH",
+ "EAI_AGAIN",
+ "UND_ERR_CONNECT_TIMEOUT",
+ "UND_ERR_DNS_RESOLVE_FAILED",
+ "UND_ERR_CONNECT",
+ "UND_ERR_SOCKET",
+ "UND_ERR_HEADERS_TIMEOUT",
+ "UND_ERR_BODY_TIMEOUT",
+]);
+
+function getErrorCause(err: unknown): unknown {
+ if (!err || typeof err !== "object") return undefined;
+ return (err as { cause?: unknown }).cause;
+}
+
+function extractErrorCodeWithCause(err: unknown): string | undefined {
+ const direct = extractErrorCode(err);
+ if (direct) return direct;
+ return extractErrorCode(getErrorCause(err));
+}
+
/**
* Checks if an error is an AbortError.
* These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
@@ -20,33 +61,14 @@ export function isAbortError(err: unknown): boolean {
return false;
}
-// Network error codes that indicate transient failures (shouldn't crash the gateway)
-const TRANSIENT_NETWORK_CODES = new Set([
- "ECONNRESET",
- "ECONNREFUSED",
- "ENOTFOUND",
- "ETIMEDOUT",
- "ESOCKETTIMEDOUT",
- "ECONNABORTED",
- "EPIPE",
- "EHOSTUNREACH",
- "ENETUNREACH",
- "EAI_AGAIN",
- "UND_ERR_CONNECT_TIMEOUT",
- "UND_ERR_SOCKET",
- "UND_ERR_HEADERS_TIMEOUT",
- "UND_ERR_BODY_TIMEOUT",
-]);
-
-function getErrorCode(err: unknown): string | undefined {
- if (!err || typeof err !== "object") return undefined;
- const code = (err as { code?: unknown }).code;
- return typeof code === "string" ? code : undefined;
+function isFatalError(err: unknown): boolean {
+ const code = extractErrorCodeWithCause(err);
+ return code !== undefined && FATAL_ERROR_CODES.has(code);
}
-function getErrorCause(err: unknown): unknown {
- if (!err || typeof err !== "object") return undefined;
- return (err as { cause?: unknown }).cause;
+function isConfigError(err: unknown): boolean {
+ const code = extractErrorCodeWithCause(err);
+ return code !== undefined && CONFIG_ERROR_CODES.has(code);
}
/**
@@ -56,16 +78,13 @@ function getErrorCause(err: unknown): unknown {
export function isTransientNetworkError(err: unknown): boolean {
if (!err) return false;
- // Check the error itself
- const code = getErrorCode(err);
+ const code = extractErrorCodeWithCause(err);
if (code && TRANSIENT_NETWORK_CODES.has(code)) return true;
// "fetch failed" TypeError from undici (Node's native fetch)
if (err instanceof TypeError && err.message === "fetch failed") {
const cause = getErrorCause(err);
- // The cause often contains the actual network error
if (cause) return isTransientNetworkError(cause);
- // Even without a cause, "fetch failed" is typically a network issue
return true;
}
@@ -115,10 +134,23 @@ export function installUnhandledRejectionHandler(): void {
return;
}
- // Transient network errors (fetch failed, connection reset, etc.) shouldn't crash
- // These are temporary connectivity issues that will resolve on their own
+ if (isFatalError(reason)) {
+ console.error("[moltbot] FATAL unhandled rejection:", formatUncaughtError(reason));
+ process.exit(1);
+ return;
+ }
+
+ if (isConfigError(reason)) {
+ console.error("[moltbot] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
+ process.exit(1);
+ return;
+ }
+
if (isTransientNetworkError(reason)) {
- console.error("[moltbot] Network error (non-fatal):", formatUncaughtError(reason));
+ console.warn(
+ "[moltbot] Non-fatal unhandled rejection (continuing):",
+ formatUncaughtError(reason),
+ );
return;
}
diff --git a/src/media/mime.test.ts b/src/media/mime.test.ts
index a3c2a35d8..92325a62e 100644
--- a/src/media/mime.test.ts
+++ b/src/media/mime.test.ts
@@ -1,7 +1,7 @@
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
-import { detectMime, imageMimeFromFormat } from "./mime.js";
+import { detectMime, extensionForMime, imageMimeFromFormat } from "./mime.js";
async function makeOoxmlZip(opts: { mainMime: string; partPath: string }): Promise {
const zip = new JSZip();
@@ -53,3 +53,47 @@ describe("mime detection", () => {
expect(mime).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
});
});
+
+describe("extensionForMime", () => {
+ it("maps image MIME types to extensions", () => {
+ expect(extensionForMime("image/jpeg")).toBe(".jpg");
+ expect(extensionForMime("image/png")).toBe(".png");
+ expect(extensionForMime("image/webp")).toBe(".webp");
+ expect(extensionForMime("image/gif")).toBe(".gif");
+ expect(extensionForMime("image/heic")).toBe(".heic");
+ });
+
+ it("maps audio MIME types to extensions", () => {
+ expect(extensionForMime("audio/mpeg")).toBe(".mp3");
+ expect(extensionForMime("audio/ogg")).toBe(".ogg");
+ expect(extensionForMime("audio/x-m4a")).toBe(".m4a");
+ expect(extensionForMime("audio/mp4")).toBe(".m4a");
+ });
+
+ it("maps video MIME types to extensions", () => {
+ expect(extensionForMime("video/mp4")).toBe(".mp4");
+ expect(extensionForMime("video/quicktime")).toBe(".mov");
+ });
+
+ it("maps document MIME types to extensions", () => {
+ expect(extensionForMime("application/pdf")).toBe(".pdf");
+ expect(extensionForMime("text/plain")).toBe(".txt");
+ expect(extensionForMime("text/markdown")).toBe(".md");
+ });
+
+ it("handles case insensitivity", () => {
+ expect(extensionForMime("IMAGE/JPEG")).toBe(".jpg");
+ expect(extensionForMime("Audio/X-M4A")).toBe(".m4a");
+ expect(extensionForMime("Video/QuickTime")).toBe(".mov");
+ });
+
+ it("returns undefined for unknown MIME types", () => {
+ expect(extensionForMime("video/unknown")).toBeUndefined();
+ expect(extensionForMime("application/x-custom")).toBeUndefined();
+ });
+
+ it("returns undefined for null or undefined input", () => {
+ expect(extensionForMime(null)).toBeUndefined();
+ expect(extensionForMime(undefined)).toBeUndefined();
+ });
+});
diff --git a/src/media/mime.ts b/src/media/mime.ts
index 79677b1cb..c50e9152c 100644
--- a/src/media/mime.ts
+++ b/src/media/mime.ts
@@ -13,7 +13,10 @@ const EXT_BY_MIME: Record = {
"image/gif": ".gif",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
+ "audio/x-m4a": ".m4a",
+ "audio/mp4": ".m4a",
"video/mp4": ".mp4",
+ "video/quicktime": ".mov",
"application/pdf": ".pdf",
"application/json": ".json",
"application/zip": ".zip",
diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts
index 6a3366e97..aed0fa755 100644
--- a/src/routing/resolve-route.test.ts
+++ b/src/routing/resolve-route.test.ts
@@ -227,3 +227,29 @@ describe("resolveAgentRoute", () => {
expect(route.sessionKey).toBe("agent:home:main");
});
});
+
+test("dmScope=per-account-channel-peer isolates DM sessions per account, channel and sender", () => {
+ const cfg: MoltbotConfig = {
+ session: { dmScope: "per-account-channel-peer" },
+ };
+ const route = resolveAgentRoute({
+ cfg,
+ channel: "telegram",
+ accountId: "tasks",
+ peer: { kind: "dm", id: "7550356539" },
+ });
+ expect(route.sessionKey).toBe("agent:main:telegram:tasks:dm:7550356539");
+});
+
+test("dmScope=per-account-channel-peer uses default accountId when not provided", () => {
+ const cfg: MoltbotConfig = {
+ session: { dmScope: "per-account-channel-peer" },
+ };
+ const route = resolveAgentRoute({
+ cfg,
+ channel: "telegram",
+ accountId: null,
+ peer: { kind: "dm", id: "7550356539" },
+ });
+ expect(route.sessionKey).toBe("agent:main:telegram:default:dm:7550356539");
+});
diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts
index 473dc61f2..0c63f77c8 100644
--- a/src/routing/resolve-route.ts
+++ b/src/routing/resolve-route.ts
@@ -69,9 +69,10 @@ function matchesAccountId(match: string | undefined, actual: string): boolean {
export function buildAgentSessionKey(params: {
agentId: string;
channel: string;
+ accountId?: string | null;
peer?: RoutePeer | null;
/** DM session scope. */
- dmScope?: "main" | "per-peer" | "per-channel-peer";
+ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
identityLinks?: Record;
}): string {
const channel = normalizeToken(params.channel) || "unknown";
@@ -80,6 +81,7 @@ export function buildAgentSessionKey(params: {
agentId: params.agentId,
mainKey: DEFAULT_MAIN_KEY,
channel,
+ accountId: params.accountId,
peerKind: peer?.kind ?? "dm",
peerId: peer ? normalizeId(peer.id) || "unknown" : null,
dmScope: params.dmScope,
@@ -160,6 +162,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR
const sessionKey = buildAgentSessionKey({
agentId: resolvedAgentId,
channel,
+ accountId,
peer,
dmScope,
identityLinks,
diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts
index 7f9f209ed..320ffeb83 100644
--- a/src/routing/session-key.ts
+++ b/src/routing/session-key.ts
@@ -111,11 +111,12 @@ export function buildAgentPeerSessionKey(params: {
agentId: string;
mainKey?: string | undefined;
channel: string;
+ accountId?: string | null;
peerKind?: "dm" | "group" | "channel" | null;
peerId?: string | null;
identityLinks?: Record;
/** DM session scope. */
- dmScope?: "main" | "per-peer" | "per-channel-peer";
+ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
}): string {
const peerKind = params.peerKind ?? "dm";
if (peerKind === "dm") {
@@ -131,6 +132,11 @@ export function buildAgentPeerSessionKey(params: {
});
if (linkedPeerId) peerId = linkedPeerId;
peerId = peerId.toLowerCase();
+ if (dmScope === "per-account-channel-peer" && peerId) {
+ const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
+ const accountId = normalizeAccountId(params.accountId);
+ return `agent:${normalizeAgentId(params.agentId)}:${channel}:${accountId}:dm:${peerId}`;
+ }
if (dmScope === "per-channel-peer" && peerId) {
const channel = (params.channel ?? "").trim().toLowerCase() || "unknown";
return `agent:${normalizeAgentId(params.agentId)}:${channel}:dm:${peerId}`;
diff --git a/src/security/audit.ts b/src/security/audit.ts
index 7aebd6928..681d14c1d 100644
--- a/src/security/audit.ts
+++ b/src/security/audit.ts
@@ -519,7 +519,8 @@ async function collectChannelSecurityFindings(params: {
title: `${input.label} DMs share the main session`,
detail:
"Multiple DM senders currently share the main session, which can leak context across users.",
- remediation: 'Set session.dmScope="per-channel-peer" to isolate DM sessions per sender.',
+ remediation:
+ 'Set session.dmScope="per-channel-peer" (or "per-account-channel-peer" for multi-account channels) to isolate DM sessions per sender.',
});
}
};
diff --git a/src/telegram/bot-message-context.dm-threads.test.ts b/src/telegram/bot-message-context.dm-threads.test.ts
index ff6a8a837..d710e0b1b 100644
--- a/src/telegram/bot-message-context.dm-threads.test.ts
+++ b/src/telegram/bot-message-context.dm-threads.test.ts
@@ -70,3 +70,102 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
});
+
+describe("buildTelegramMessageContext group sessions without forum", () => {
+ const baseConfig = {
+ agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
+ channels: { telegram: {} },
+ messages: { groupChat: { mentionPatterns: [] } },
+ } as never;
+
+ const buildContext = async (message: Record) =>
+ await buildTelegramMessageContext({
+ primaryCtx: {
+ message,
+ me: { id: 7, username: "bot" },
+ } as never,
+ allMedia: [],
+ storeAllowFrom: [],
+ options: { forceWasMentioned: true },
+ bot: {
+ api: {
+ sendChatAction: vi.fn(),
+ setMessageReaction: vi.fn(),
+ },
+ } as never,
+ cfg: baseConfig,
+ account: { accountId: "default" } as never,
+ historyLimit: 0,
+ groupHistories: new Map(),
+ dmPolicy: "open",
+ allowFrom: [],
+ groupAllowFrom: [],
+ ackReactionScope: "off",
+ logger: { info: vi.fn() },
+ resolveGroupActivation: () => true,
+ resolveGroupRequireMention: () => false,
+ resolveTelegramGroupConfig: () => ({
+ groupConfig: { requireMention: false },
+ topicConfig: undefined,
+ }),
+ });
+
+ it("ignores message_thread_id for regular groups (not forums)", async () => {
+ // When someone replies to a message in a non-forum group, Telegram sends
+ // message_thread_id but this should NOT create a separate session
+ const ctx = await buildContext({
+ message_id: 1,
+ chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
+ date: 1700000000,
+ text: "@bot hello",
+ message_thread_id: 42, // This is a reply thread, NOT a forum topic
+ from: { id: 42, first_name: "Alice" },
+ });
+
+ expect(ctx).not.toBeNull();
+ // Session key should NOT include :topic:42
+ expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890");
+ // MessageThreadId should be undefined (not a forum)
+ expect(ctx?.ctxPayload?.MessageThreadId).toBeUndefined();
+ });
+
+ it("keeps same session for regular group with and without message_thread_id", async () => {
+ const ctxWithThread = await buildContext({
+ message_id: 1,
+ chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
+ date: 1700000000,
+ text: "@bot hello",
+ message_thread_id: 42,
+ from: { id: 42, first_name: "Alice" },
+ });
+
+ const ctxWithoutThread = await buildContext({
+ message_id: 2,
+ chat: { id: -1001234567890, type: "supergroup", title: "Test Group" },
+ date: 1700000001,
+ text: "@bot world",
+ from: { id: 42, first_name: "Alice" },
+ });
+
+ expect(ctxWithThread).not.toBeNull();
+ expect(ctxWithoutThread).not.toBeNull();
+ // Both messages should use the same session key
+ expect(ctxWithThread?.ctxPayload?.SessionKey).toBe(ctxWithoutThread?.ctxPayload?.SessionKey);
+ });
+
+ it("uses topic session for forum groups with message_thread_id", async () => {
+ const ctx = await buildContext({
+ message_id: 1,
+ chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
+ date: 1700000000,
+ text: "@bot hello",
+ message_thread_id: 99,
+ from: { id: 42, first_name: "Alice" },
+ });
+
+ expect(ctx).not.toBeNull();
+ // Session key SHOULD include :topic:99 for forums
+ expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:telegram:group:-1001234567890:topic:99");
+ expect(ctx?.ctxPayload?.MessageThreadId).toBe(99);
+ });
+});
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index aa6dcd88b..832a4413d 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -173,7 +173,8 @@ export const buildTelegramMessageContext = async ({
},
});
const baseSessionKey = route.sessionKey;
- const dmThreadId = !isGroup ? resolvedThreadId : undefined;
+ // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
+ const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@@ -601,7 +602,8 @@ export const buildTelegramMessageContext = async ({
Sticker: allMedia[0]?.stickerMetadata,
...(locationData ? toLocationContext(locationData) : undefined),
CommandAuthorized: commandAuthorized,
- MessageThreadId: resolvedThreadId,
+ // For groups: use resolvedThreadId (forum topics only); for DMs: use raw messageThreadId
+ MessageThreadId: isGroup ? resolvedThreadId : messageThreadId,
IsForum: isForum,
// Originating channel for reply routing.
OriginatingChannel: "telegram" as const,
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index 4cca71d14..3415ea927 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -322,7 +322,7 @@ export const registerTelegramNativeCommands = ({
];
if (allCommands.length > 0) {
- void withTelegramApiErrorLogging({
+ withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands(allCommands),
@@ -360,6 +360,8 @@ export const registerTelegramNativeCommands = ({
topicConfig,
commandAuthorized,
} = auth;
+ const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
+ const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const commandDefinition = findCommandByNativeName(command.name, "telegram");
const rawText = ctx.match?.trim() ?? "";
@@ -406,7 +408,7 @@ export const registerTelegramNativeCommands = ({
fn: () =>
bot.api.sendMessage(chatId, title, {
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
- ...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
+ ...(threadIdForSend != null ? { message_thread_id: threadIdForSend } : {}),
}),
});
return;
@@ -421,7 +423,8 @@ export const registerTelegramNativeCommands = ({
},
});
const baseSessionKey = route.sessionKey;
- const dmThreadId = !isGroup ? resolvedThreadId : undefined;
+ // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
+ const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
@@ -466,7 +469,7 @@ export const registerTelegramNativeCommands = ({
CommandSource: "native" as const,
SessionKey: `telegram:slash:${senderId || chatId}`,
CommandTargetSessionKey: sessionKey,
- MessageThreadId: resolvedThreadId,
+ MessageThreadId: threadIdForSend,
IsForum: isForum,
// Originating context for sub-agent announce routing
OriginatingChannel: "telegram" as const,
@@ -493,7 +496,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
- messageThreadId: resolvedThreadId,
+ messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
@@ -541,7 +544,9 @@ export const registerTelegramNativeCommands = ({
requireAuth: match.command.requireAuth !== false,
});
if (!auth) return;
- const { resolvedThreadId, senderId, commandAuthorized } = auth;
+ const { resolvedThreadId, senderId, commandAuthorized, isGroup } = auth;
+ const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
+ const threadIdForSend = isGroup ? resolvedThreadId : messageThreadId;
const result = await executePluginCommand({
command: match.command,
@@ -567,7 +572,7 @@ export const registerTelegramNativeCommands = ({
bot,
replyToMode,
textLimit,
- messageThreadId: resolvedThreadId,
+ messageThreadId: threadIdForSend,
tableMode,
chunkMode,
linkPreview: telegramCfg.linkPreview,
@@ -576,7 +581,7 @@ export const registerTelegramNativeCommands = ({
}
}
} else if (nativeDisabledExplicit) {
- void withTelegramApiErrorLogging({
+ withTelegramApiErrorLogging({
operation: "setMyCommands",
runtime,
fn: () => bot.api.setMyCommands([]),
diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
index bf94e4f6f..c3844ac88 100644
--- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
+++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts
@@ -238,12 +238,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
- message: { chat: { id: 123 }, message_thread_id: 9 },
+ message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
- message: { chat: { id: 123, is_forum: true } },
+ message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
+ }),
+ ).toBe("telegram:123");
+ expect(
+ getTelegramSequentialKey({
+ message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(
diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts
index 75dd32faf..c075174fb 100644
--- a/src/telegram/bot.test.ts
+++ b/src/telegram/bot.test.ts
@@ -340,12 +340,17 @@ describe("createTelegramBot", () => {
expect(getTelegramSequentialKey({ message: { chat: { id: 123 } } })).toBe("telegram:123");
expect(
getTelegramSequentialKey({
- message: { chat: { id: 123 }, message_thread_id: 9 },
+ message: { chat: { id: 123, type: "private" }, message_thread_id: 9 },
}),
).toBe("telegram:123:topic:9");
expect(
getTelegramSequentialKey({
- message: { chat: { id: 123, is_forum: true } },
+ message: { chat: { id: 123, type: "supergroup" }, message_thread_id: 9 },
+ }),
+ ).toBe("telegram:123");
+ expect(
+ getTelegramSequentialKey({
+ message: { chat: { id: 123, type: "supergroup", is_forum: true } },
}),
).toBe("telegram:123:topic:1");
expect(
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index 655e1b427..ae21d10da 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -94,11 +94,12 @@ export function getTelegramSequentialKey(ctx: {
if (typeof chatId === "number") return `telegram:${chatId}:control`;
return "telegram:control";
}
+ const isGroup = msg?.chat?.type === "group" || msg?.chat?.type === "supergroup";
+ const messageThreadId = msg?.message_thread_id;
const isForum = (msg?.chat as { is_forum?: boolean } | undefined)?.is_forum;
- const threadId = resolveTelegramForumThreadId({
- isForum,
- messageThreadId: msg?.message_thread_id,
- });
+ const threadId = isGroup
+ ? resolveTelegramForumThreadId({ isForum, messageThreadId })
+ : messageThreadId;
if (typeof chatId === "number") {
return threadId != null ? `telegram:${chatId}:topic:${threadId}` : `telegram:${chatId}`;
}
@@ -427,7 +428,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
peer: { kind: isGroup ? "group" : "dm", id: peerId },
});
const baseSessionKey = route.sessionKey;
- const dmThreadId = !isGroup ? resolvedThreadId : undefined;
+ // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
+ const dmThreadId = !isGroup ? messageThreadId : undefined;
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: String(dmThreadId) })
diff --git a/src/telegram/bot/helpers.test.ts b/src/telegram/bot/helpers.test.ts
index 60fbba0dc..8e90bb520 100644
--- a/src/telegram/bot/helpers.test.ts
+++ b/src/telegram/bot/helpers.test.ts
@@ -3,8 +3,34 @@ import {
buildTelegramThreadParams,
buildTypingThreadParams,
normalizeForwardedContext,
+ resolveTelegramForumThreadId,
} from "./helpers.js";
+describe("resolveTelegramForumThreadId", () => {
+ it("returns undefined for non-forum groups even with messageThreadId", () => {
+ // Reply threads in regular groups should not create separate sessions
+ expect(resolveTelegramForumThreadId({ isForum: false, messageThreadId: 42 })).toBeUndefined();
+ });
+
+ it("returns undefined for non-forum groups without messageThreadId", () => {
+ expect(
+ resolveTelegramForumThreadId({ isForum: false, messageThreadId: undefined }),
+ ).toBeUndefined();
+ expect(
+ resolveTelegramForumThreadId({ isForum: undefined, messageThreadId: 99 }),
+ ).toBeUndefined();
+ });
+
+ it("returns General topic (1) for forum groups without messageThreadId", () => {
+ expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: undefined })).toBe(1);
+ expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: null })).toBe(1);
+ });
+
+ it("returns the topic id for forum groups with messageThreadId", () => {
+ expect(resolveTelegramForumThreadId({ isForum: true, messageThreadId: 99 })).toBe(99);
+ });
+});
+
describe("buildTelegramThreadParams", () => {
it("omits General topic thread id for message sends", () => {
expect(buildTelegramThreadParams(1)).toBeUndefined();
diff --git a/src/telegram/bot/helpers.ts b/src/telegram/bot/helpers.ts
index 19b8e76c0..cd57392c0 100644
--- a/src/telegram/bot/helpers.ts
+++ b/src/telegram/bot/helpers.ts
@@ -13,14 +13,25 @@ import type {
const TELEGRAM_GENERAL_TOPIC_ID = 1;
+/**
+ * Resolve the thread ID for Telegram forum topics.
+ * For non-forum groups, returns undefined even if messageThreadId is present
+ * (reply threads in regular groups should not create separate sessions).
+ * For forum groups, returns the topic ID (or General topic ID=1 if unspecified).
+ */
export function resolveTelegramForumThreadId(params: {
isForum?: boolean;
messageThreadId?: number | null;
}) {
- if (params.isForum && params.messageThreadId == null) {
+ // Non-forum groups: ignore message_thread_id (reply threads are not real topics)
+ if (!params.isForum) {
+ return undefined;
+ }
+ // Forum groups: use the topic ID, defaulting to General topic
+ if (params.messageThreadId == null) {
return TELEGRAM_GENERAL_TOPIC_ID;
}
- return params.messageThreadId ?? undefined;
+ return params.messageThreadId;
}
/**
diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts
index 59df7098d..c3b3a5a2f 100644
--- a/src/telegram/monitor.ts
+++ b/src/telegram/monitor.ts
@@ -74,6 +74,23 @@ const isGetUpdatesConflict = (err: unknown) => {
return haystack.includes("getupdates");
};
+const NETWORK_ERROR_SNIPPETS = [
+ "fetch failed",
+ "network",
+ "timeout",
+ "socket",
+ "econnreset",
+ "econnrefused",
+ "undici",
+];
+
+const isNetworkRelatedError = (err: unknown) => {
+ if (!err) return false;
+ const message = formatErrorMessage(err).toLowerCase();
+ if (!message) return false;
+ return NETWORK_ERROR_SNIPPETS.some((snippet) => message.includes(snippet));
+};
+
export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveTelegramAccount({
@@ -158,7 +175,8 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) {
}
const isConflict = isGetUpdatesConflict(err);
const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" });
- if (!isConflict && !isRecoverable) {
+ const isNetworkError = isNetworkRelatedError(err);
+ if (!isConflict && !isRecoverable && !isNetworkError) {
throw err;
}
restartAttempts += 1;
diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts
index ae42cbb97..db582355f 100644
--- a/src/telegram/network-errors.test.ts
+++ b/src/telegram/network-errors.test.ts
@@ -8,6 +8,13 @@ describe("isRecoverableTelegramNetworkError", () => {
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
+ it("detects additional recoverable error codes", () => {
+ const aborted = Object.assign(new Error("aborted"), { code: "ECONNABORTED" });
+ const network = Object.assign(new Error("network"), { code: "ERR_NETWORK" });
+ expect(isRecoverableTelegramNetworkError(aborted)).toBe(true);
+ expect(isRecoverableTelegramNetworkError(network)).toBe(true);
+ });
+
it("detects AbortError names", () => {
const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
@@ -19,6 +26,11 @@ describe("isRecoverableTelegramNetworkError", () => {
expect(isRecoverableTelegramNetworkError(err)).toBe(true);
});
+ it("detects expanded message patterns", () => {
+ expect(isRecoverableTelegramNetworkError(new Error("TypeError: fetch failed"))).toBe(true);
+ expect(isRecoverableTelegramNetworkError(new Error("Undici: socket failure"))).toBe(true);
+ });
+
it("skips message matches for send context", () => {
const err = new TypeError("fetch failed");
expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false);
diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts
index 70cd81994..bb3432432 100644
--- a/src/telegram/network-errors.ts
+++ b/src/telegram/network-errors.ts
@@ -15,6 +15,8 @@ const RECOVERABLE_ERROR_CODES = new Set([
"UND_ERR_BODY_TIMEOUT",
"UND_ERR_SOCKET",
"UND_ERR_ABORTED",
+ "ECONNABORTED",
+ "ERR_NETWORK",
]);
const RECOVERABLE_ERROR_NAMES = new Set([
@@ -27,6 +29,8 @@ const RECOVERABLE_ERROR_NAMES = new Set([
const RECOVERABLE_MESSAGE_SNIPPETS = [
"fetch failed",
+ "typeerror: fetch failed",
+ "undici",
"network error",
"network request",
"client network socket disconnected",
diff --git a/src/utils.test.ts b/src/utils.test.ts
index 686808a46..769c98a4f 100644
--- a/src/utils.test.ts
+++ b/src/utils.test.ts
@@ -9,6 +9,7 @@ import {
jidToE164,
normalizeE164,
normalizePath,
+ resolveConfigDir,
resolveJidToE164,
resolveUserPath,
sleep,
@@ -120,6 +121,20 @@ describe("jidToE164", () => {
});
});
+describe("resolveConfigDir", () => {
+ it("prefers ~/.moltbot when legacy dir is missing", async () => {
+ const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), "moltbot-config-dir-"));
+ try {
+ const newDir = path.join(root, ".moltbot");
+ await fs.promises.mkdir(newDir, { recursive: true });
+ const resolved = resolveConfigDir({} as NodeJS.ProcessEnv, () => root);
+ expect(resolved).toBe(newDir);
+ } finally {
+ await fs.promises.rm(root, { recursive: true, force: true });
+ }
+ });
+});
+
describe("resolveJidToE164", () => {
it("resolves @lid via lidLookup when mapping file is missing", async () => {
const lidLookup = {
diff --git a/src/utils.ts b/src/utils.ts
index cdb56c7ee..7c441f4f1 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -215,9 +215,18 @@ export function resolveConfigDir(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = os.homedir,
): string {
- const override = env.CLAWDBOT_STATE_DIR?.trim();
+ const override = env.MOLTBOT_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
if (override) return resolveUserPath(override);
- return path.join(homedir(), ".clawdbot");
+ const legacyDir = path.join(homedir(), ".clawdbot");
+ const newDir = path.join(homedir(), ".moltbot");
+ try {
+ const hasLegacy = fs.existsSync(legacyDir);
+ const hasNew = fs.existsSync(newDir);
+ if (!hasLegacy && hasNew) return newDir;
+ } catch {
+ // best-effort
+ }
+ return legacyDir;
}
export function resolveHomeDir(): string | undefined {
diff --git a/src/web/auto-reply/monitor/broadcast.ts b/src/web/auto-reply/monitor/broadcast.ts
index ef76ce3b0..c8f84a048 100644
--- a/src/web/auto-reply/monitor/broadcast.ts
+++ b/src/web/auto-reply/monitor/broadcast.ts
@@ -54,11 +54,13 @@ export async function maybeBroadcastMessage(params: {
sessionKey: buildAgentSessionKey({
agentId: normalizedAgentId,
channel: "whatsapp",
+ accountId: params.route.accountId,
peer: {
kind: params.msg.chatType === "group" ? "group" : "dm",
id: params.peerId,
},
dmScope: params.cfg.session?.dmScope,
+ identityLinks: params.cfg.session?.identityLinks,
}),
mainSessionKey: buildAgentMainSessionKey({
agentId: normalizedAgentId,