diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed99095aa..d1a29c4d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,8 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- 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.
- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
@@ -62,6 +64,7 @@ Status: unreleased.
- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein.
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
+- Agents: release session locks on process termination. (#2483) Thanks @janeexai.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
diff --git a/README.md b/README.md
index 2fdb6414a..a5daba163 100644
--- a/README.md
+++ b/README.md
@@ -479,36 +479,34 @@ Thanks to all clawtributors:
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts
index 8f93bface..8eafd6bf4 100644
--- a/src/agents/session-write-lock.test.ts
+++ b/src/agents/session-write-lock.test.ts
@@ -31,4 +31,94 @@ describe("acquireSessionWriteLock", () => {
await fs.rm(root, { recursive: true, force: true });
}
});
+
+ it("keeps the lock file until the last release", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+
+ const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+ const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ await expect(fs.access(lockPath)).resolves.toBeUndefined();
+ await lockA.release();
+ await expect(fs.access(lockPath)).resolves.toBeUndefined();
+ await lockB.release();
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("reclaims stale lock files", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await fs.writeFile(
+ lockPath,
+ JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }),
+ "utf8",
+ );
+
+ const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 });
+ const raw = await fs.readFile(lockPath, "utf8");
+ const payload = JSON.parse(raw) as { pid: number };
+
+ expect(payload.pid).toBe(process.pid);
+ await lock.release();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("cleans up locks on SIGINT without removing other handlers", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ const originalKill = process.kill;
+ const killCalls: Array = [];
+ let otherHandlerCalled = false;
+
+ process.kill = ((pid: number, signal?: NodeJS.Signals) => {
+ killCalls.push(signal);
+ return true;
+ }) as typeof process.kill;
+
+ const otherHandler = () => {
+ otherHandlerCalled = true;
+ };
+
+ process.on("SIGINT", otherHandler);
+
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ process.emit("SIGINT");
+
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ expect(otherHandlerCalled).toBe(true);
+ expect(killCalls).toEqual(["SIGINT"]);
+ } finally {
+ process.off("SIGINT", otherHandler);
+ process.kill = originalKill;
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
+
+ it("cleans up locks on exit", async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
+ try {
+ const sessionFile = path.join(root, "sessions.json");
+ const lockPath = `${sessionFile}.lock`;
+ await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 });
+
+ process.emit("exit", 0);
+
+ await expect(fs.access(lockPath)).rejects.toThrow();
+ } finally {
+ await fs.rm(root, { recursive: true, force: true });
+ }
+ });
});
diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts
index 54e61d965..d7499eb2a 100644
--- a/src/agents/session-write-lock.ts
+++ b/src/agents/session-write-lock.ts
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
+import fsSync from "node:fs";
import path from "node:path";
type LockFilePayload = {
@@ -116,3 +117,53 @@ export async function acquireSessionWriteLock(params: {
const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
}
+
+/**
+ * Synchronously release all held locks.
+ * Used during process exit when async operations aren't reliable.
+ */
+function releaseAllLocksSync(): void {
+ for (const [sessionFile, held] of HELD_LOCKS) {
+ try {
+ fsSync.closeSync(held.handle.fd);
+ } catch {
+ // Ignore close errors during cleanup - best effort
+ }
+ try {
+ fsSync.rmSync(held.lockPath, { force: true });
+ } catch {
+ // Ignore errors during cleanup - best effort
+ }
+ HELD_LOCKS.delete(sessionFile);
+ }
+}
+
+let cleanupRegistered = false;
+
+function registerCleanupHandlers(): void {
+ if (cleanupRegistered) return;
+ cleanupRegistered = true;
+
+ // Cleanup on normal exit and process.exit() calls
+ process.on("exit", () => {
+ releaseAllLocksSync();
+ });
+
+ // Handle SIGINT (Ctrl+C) and SIGTERM
+ const handleSignal = (signal: NodeJS.Signals) => {
+ releaseAllLocksSync();
+ // Remove only our handlers and re-raise signal for proper exit code.
+ process.off("SIGINT", onSigInt);
+ process.off("SIGTERM", onSigTerm);
+ process.kill(process.pid, signal);
+ };
+
+ const onSigInt = () => handleSignal("SIGINT");
+ const onSigTerm = () => handleSignal("SIGTERM");
+
+ process.on("SIGINT", onSigInt);
+ process.on("SIGTERM", onSigTerm);
+}
+
+// Register cleanup handlers when module loads
+registerCleanupHandlers();
diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts
index 12fec300b..5ba6826fe 100644
--- a/src/auto-reply/commands-registry.data.ts
+++ b/src/auto-reply/commands-registry.data.ts
@@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] {
defineChatCommand({
key: "tts",
nativeName: "tts",
- description: "Configure text-to-speech.",
+ description: "Control text-to-speech (TTS).",
textAlias: "/tts",
- acceptsArgs: true,
+ args: [
+ {
+ name: "action",
+ description: "TTS action",
+ type: "string",
+ choices: [
+ { value: "on", label: "On" },
+ { value: "off", label: "Off" },
+ { value: "status", label: "Status" },
+ { value: "provider", label: "Provider" },
+ { value: "limit", label: "Limit" },
+ { value: "summary", label: "Summary" },
+ { value: "audio", label: "Audio" },
+ { value: "help", label: "Help" },
+ ],
+ },
+ {
+ name: "value",
+ description: "Provider, limit, or text",
+ type: "string",
+ captureRemaining: true,
+ },
+ ],
+ argsMenu: {
+ arg: "action",
+ title:
+ "TTS Actions:\n" +
+ "• On – Enable TTS for responses\n" +
+ "• Off – Disable TTS\n" +
+ "• Status – Show current settings\n" +
+ "• Provider – Set voice provider (edge, elevenlabs, openai)\n" +
+ "• Limit – Set max characters for TTS\n" +
+ "• Summary – Toggle AI summary for long texts\n" +
+ "• Audio – Generate TTS from custom text\n" +
+ "• Help – Show usage guide",
+ },
}),
defineChatCommand({
key: "whoami",
diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts
index 6a6efbced..69f3ac1ae 100644
--- a/src/auto-reply/commands-registry.test.ts
+++ b/src/auto-reply/commands-registry.test.ts
@@ -229,7 +229,12 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
- expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
+ expect(menu?.choices).toEqual([
+ { label: "off", value: "off" },
+ { label: "tokens", value: "tokens" },
+ { label: "full", value: "full" },
+ { label: "cost", value: "cost" },
+ ]);
});
it("does not show menus when arg already provided", () => {
@@ -284,7 +289,10 @@ describe("commands registry args", () => {
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
- expect(menu?.choices).toEqual(["low", "high"]);
+ expect(menu?.choices).toEqual([
+ { label: "low", value: "low" },
+ { label: "high", value: "high" },
+ ]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts
index 5bca565f0..f772ac7fc 100644
--- a/src/auto-reply/commands-registry.ts
+++ b/src/auto-reply/commands-registry.ts
@@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): {
};
}
+export type ResolvedCommandArgChoice = { value: string; label: string };
+
export function resolveCommandArgChoices(params: {
command: ChatCommandDefinition;
arg: CommandArgDefinition;
cfg?: ClawdbotConfig;
provider?: string;
model?: string;
-}): string[] {
+}): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params;
if (!arg.choices) return [];
const provided = arg.choices;
- if (Array.isArray(provided)) return provided;
- const defaults = resolveDefaultCommandContext(cfg);
- const context: CommandArgChoiceContext = {
- cfg,
- provider: params.provider ?? defaults.provider,
- model: params.model ?? defaults.model,
- command,
- arg,
- };
- return provided(context);
+ const raw = Array.isArray(provided)
+ ? provided
+ : (() => {
+ const defaults = resolveDefaultCommandContext(cfg);
+ const context: CommandArgChoiceContext = {
+ cfg,
+ provider: params.provider ?? defaults.provider,
+ model: params.model ?? defaults.model,
+ command,
+ arg,
+ };
+ return provided(context);
+ })();
+ return raw.map((choice) =>
+ typeof choice === "string" ? { value: choice, label: choice } : choice,
+ );
}
export function resolveCommandArgMenu(params: {
command: ChatCommandDefinition;
args?: CommandArgs;
cfg?: ClawdbotConfig;
-}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null {
+}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
const { command, args, cfg } = params;
if (!command.args || !command.argsMenu) return null;
if (command.argsParsing === "none") return null;
diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts
index c19c9d9a7..5e5bdd8cb 100644
--- a/src/auto-reply/commands-registry.types.ts
+++ b/src/auto-reply/commands-registry.types.ts
@@ -12,14 +12,16 @@ export type CommandArgChoiceContext = {
arg: CommandArgDefinition;
};
-export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[];
+export type CommandArgChoice = string | { value: string; label: string };
+
+export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[];
export type CommandArgDefinition = {
name: string;
description: string;
type: CommandArgType;
required?: boolean;
- choices?: string[] | CommandArgChoicesProvider;
+ choices?: CommandArgChoice[] | CommandArgChoicesProvider;
captureRemaining?: boolean;
};
diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts
index 5c65fb94c..04b60a4e9 100644
--- a/src/auto-reply/reply/commands-tts.ts
+++ b/src/auto-reply/reply/commands-tts.ts
@@ -6,20 +6,18 @@ import {
getTtsMaxLength,
getTtsProvider,
isSummarizationEnabled,
+ isTtsEnabled,
isTtsProviderConfigured,
- normalizeTtsAutoMode,
- resolveTtsAutoMode,
resolveTtsApiKey,
resolveTtsConfig,
resolveTtsPrefsPath,
- resolveTtsProviderOrder,
setLastTtsAttempt,
setSummarizationEnabled,
+ setTtsEnabled,
setTtsMaxLength,
setTtsProvider,
textToSpeech,
} from "../../tts/tts.js";
-import { updateSessionStore } from "../../config/sessions.js";
type ParsedTtsCommand = {
action: string;
@@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload {
// Keep usage in one place so help/validation stays consistent.
return {
text:
- "⚙️ Usage: /tts [value]" +
- "\nExamples:\n" +
- "/tts always\n" +
- "/tts provider openai\n" +
- "/tts provider edge\n" +
- "/tts limit 2000\n" +
- "/tts summary off\n" +
- "/tts audio Hello from Clawdbot",
+ `🔊 **TTS (Text-to-Speech) Help**\n\n` +
+ `**Commands:**\n` +
+ `• /tts on — Enable automatic TTS for replies\n` +
+ `• /tts off — Disable TTS\n` +
+ `• /tts status — Show current settings\n` +
+ `• /tts provider [name] — View/change provider\n` +
+ `• /tts limit [number] — View/change text limit\n` +
+ `• /tts summary [on|off] — View/change auto-summary\n` +
+ `• /tts audio — Generate audio from text\n\n` +
+ `**Providers:**\n` +
+ `• edge — Free, fast (default)\n` +
+ `• openai — High quality (requires API key)\n` +
+ `• elevenlabs — Premium voices (requires API key)\n\n` +
+ `**Text Limit (default: 1500, max: 4096):**\n` +
+ `When text exceeds the limit:\n` +
+ `• Summary ON: AI summarizes, then generates audio\n` +
+ `• Summary OFF: Truncates text, then generates audio\n\n` +
+ `**Examples:**\n` +
+ `/tts provider edge\n` +
+ `/tts limit 2000\n` +
+ `/tts audio Hello, this is a test!`,
};
}
@@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
return { shouldContinue: false, reply: ttsUsage() };
}
- const requestedAuto = normalizeTtsAutoMode(
- action === "on" ? "always" : action === "off" ? "off" : action,
- );
- if (requestedAuto) {
- const entry = params.sessionEntry;
- const sessionKey = params.sessionKey;
- const store = params.sessionStore;
- if (entry && store && sessionKey) {
- entry.ttsAuto = requestedAuto;
- entry.updatedAt = Date.now();
- store[sessionKey] = entry;
- if (params.storePath) {
- await updateSessionStore(params.storePath, (store) => {
- store[sessionKey] = entry;
- });
- }
- }
- const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto;
- return {
- shouldContinue: false,
- reply: {
- text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`,
- },
- };
+ if (action === "on") {
+ setTtsEnabled(prefsPath, true);
+ return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } };
+ }
+
+ if (action === "off") {
+ setTtsEnabled(prefsPath, false);
+ return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } };
}
if (action === "audio") {
if (!args.trim()) {
- return { shouldContinue: false, reply: ttsUsage() };
+ return {
+ shouldContinue: false,
+ reply: {
+ text:
+ `🎤 Generate audio from text.\n\n` +
+ `Usage: /tts audio \n` +
+ `Example: /tts audio Hello, this is a test!`,
+ },
+ };
}
const start = Date.now();
@@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "provider") {
const currentProvider = getTtsProvider(config, prefsPath);
if (!args.trim()) {
- const fallback = resolveTtsProviderOrder(currentProvider)
- .slice(1)
- .filter((provider) => isTtsProviderConfigured(config, provider));
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
const hasEdge = isTtsProviderConfigured(config, "edge");
@@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
text:
`🎙️ TTS provider\n` +
`Primary: ${currentProvider}\n` +
- `Fallbacks: ${fallback.join(", ") || "none"}\n` +
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
@@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
setTtsProvider(prefsPath, requested);
- const fallback = resolveTtsProviderOrder(requested)
- .slice(1)
- .filter((provider) => isTtsProviderConfigured(config, provider));
return {
shouldContinue: false,
- reply: {
- text:
- `✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` +
- (requested === "edge"
- ? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true."
- : ""),
- },
+ reply: { text: `✅ TTS provider set to ${requested}.` },
};
}
@@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
const currentLimit = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
- reply: { text: `📏 TTS limit: ${currentLimit} characters.` },
+ reply: {
+ text:
+ `📏 TTS limit: ${currentLimit} characters.\n\n` +
+ `Text longer than this triggers summary (if enabled).\n` +
+ `Range: 100-4096 chars (Telegram max).\n\n` +
+ `To change: /tts limit \n` +
+ `Example: /tts limit 2000`,
+ },
};
}
const next = Number.parseInt(args.trim(), 10);
- if (!Number.isFinite(next) || next < 100 || next > 10_000) {
- return { shouldContinue: false, reply: ttsUsage() };
+ if (!Number.isFinite(next) || next < 100 || next > 4096) {
+ return {
+ shouldContinue: false,
+ reply: { text: "❌ Limit must be between 100 and 4096 characters." },
+ };
}
setTtsMaxLength(prefsPath, next);
return {
@@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
if (action === "summary") {
if (!args.trim()) {
const enabled = isSummarizationEnabled(prefsPath);
+ const maxLen = getTtsMaxLength(prefsPath);
return {
shouldContinue: false,
- reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` },
+ reply: {
+ text:
+ `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` +
+ `When text exceeds ${maxLen} chars:\n` +
+ `• ON: summarizes text, then generates audio\n` +
+ `• OFF: truncates text, then generates audio\n\n` +
+ `To change: /tts summary on | off`,
+ },
};
}
const requested = args.trim().toLowerCase();
@@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (action === "status") {
- const sessionAuto = params.sessionEntry?.ttsAuto;
- const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto });
- const enabled = autoMode !== "off";
+ const enabled = isTtsEnabled(config, prefsPath);
const provider = getTtsProvider(config, prefsPath);
const hasKey = isTtsProviderConfigured(config, provider);
- const providerStatus =
- provider === "edge"
- ? hasKey
- ? "✅ enabled"
- : "❌ disabled"
- : hasKey
- ? "✅ key"
- : "❌ no key";
const maxLength = getTtsMaxLength(prefsPath);
const summarize = isSummarizationEnabled(prefsPath);
const last = getLastTtsAttempt();
- const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode;
const lines = [
"📊 TTS status",
- `Auto: ${enabled ? autoLabel : "off"}`,
- `Provider: ${provider} (${providerStatus})`,
+ `State: ${enabled ? "✅ enabled" : "❌ disabled"}`,
+ `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`,
`Text limit: ${maxLength} chars`,
`Auto-summary: ${summarize ? "on" : "off"}`,
];
diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts
index 7078c15dc..fd8236c95 100644
--- a/src/auto-reply/reply/commands.test.ts
+++ b/src/auto-reply/reply/commands.test.ts
@@ -420,3 +420,17 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Status: done");
});
});
+
+describe("handleCommands /tts", () => {
+ it("returns status for bare /tts on text command surfaces", async () => {
+ const cfg = {
+ commands: { text: true },
+ channels: { whatsapp: { allowFrom: ["*"] } },
+ messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } },
+ } as ClawdbotConfig;
+ const params = buildParams("/tts", cfg);
+ const result = await handleCommands(params);
+ expect(result.shouldContinue).toBe(false);
+ expect(result.reply?.text).toContain("TTS status");
+ });
+});
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index f946c05f9..1dcd770bc 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js";
import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
-import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js";
+import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js";
const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i;
const AUDIO_HEADER_RE = /^\[Audio\b/i;
@@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: {
return { queuedFinal, counts };
}
+ // Track accumulated block text for TTS generation after streaming completes.
+ // When block streaming succeeds, there's no final reply, so we need to generate
+ // TTS audio separately from the accumulated block content.
+ let accumulatedBlockText = "";
+ let blockCount = 0;
+
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
ctx,
{
...params.replyOptions,
onBlockReply: (payload: ReplyPayload, context) => {
const run = async () => {
+ // Accumulate block text for TTS generation after streaming
+ if (payload.text) {
+ if (accumulatedBlockText.length > 0) {
+ accumulatedBlockText += "\n";
+ }
+ accumulatedBlockText += payload.text;
+ blockCount++;
+ }
const ttsPayload = await maybeApplyTtsToPayload({
payload,
cfg,
@@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: {
queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal;
}
}
+
+ const ttsMode = resolveTtsConfig(cfg).mode ?? "final";
+ // Generate TTS-only reply after block streaming completes (when there's no final reply).
+ // This handles the case where block streaming succeeds and drops final payloads,
+ // but we still want TTS audio to be generated from the accumulated block content.
+ if (
+ ttsMode === "final" &&
+ replies.length === 0 &&
+ blockCount > 0 &&
+ accumulatedBlockText.trim()
+ ) {
+ try {
+ const ttsSyntheticReply = await maybeApplyTtsToPayload({
+ payload: { text: accumulatedBlockText },
+ cfg,
+ channel: ttsChannel,
+ kind: "final",
+ inboundAudio,
+ ttsAuto: sessionTtsAuto,
+ });
+ // Only send if TTS was actually applied (mediaUrl exists)
+ if (ttsSyntheticReply.mediaUrl) {
+ // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content
+ const ttsOnlyPayload: ReplyPayload = {
+ mediaUrl: ttsSyntheticReply.mediaUrl,
+ audioAsVoice: ttsSyntheticReply.audioAsVoice,
+ };
+ if (shouldRouteToOriginating && originatingChannel && originatingTo) {
+ const result = await routeReply({
+ payload: ttsOnlyPayload,
+ channel: originatingChannel,
+ to: originatingTo,
+ sessionKey: ctx.SessionKey,
+ accountId: ctx.AccountId,
+ threadId: ctx.MessageThreadId,
+ cfg,
+ });
+ queuedFinal = result.ok || queuedFinal;
+ if (result.ok) routedFinalCount += 1;
+ if (!result.ok) {
+ logVerbose(
+ `dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`,
+ );
+ }
+ } else {
+ const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload);
+ queuedFinal = didQueue || queuedFinal;
+ }
+ }
+ } catch (err) {
+ logVerbose(
+ `dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ }
+
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts
index 75c9b3b2b..2340da2da 100644
--- a/src/discord/monitor/native-command.ts
+++ b/src/discord/monitor/native-command.ts
@@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: {
typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : "";
const choices = resolveCommandArgChoices({ command, arg, cfg });
const filtered = focusValue
- ? choices.filter((choice) => choice.toLowerCase().includes(focusValue))
+ ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue))
: choices;
await interaction.respond(
- filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })),
+ filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })),
);
}
: undefined;
const choices =
resolvedChoices.length > 0 && !autocomplete
- ? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice }))
+ ? resolvedChoices
+ .slice(0, 25)
+ .map((choice) => ({ name: choice.label, value: choice.value }))
: undefined;
return {
name: arg.name,
@@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC
function buildDiscordCommandArgMenu(params: {
command: ChatCommandDefinition;
- menu: { arg: CommandArgDefinition; choices: string[]; title?: string };
+ menu: {
+ arg: CommandArgDefinition;
+ choices: Array<{ value: string; label: string }>;
+ title?: string;
+ };
interaction: CommandInteraction;
cfg: ReturnType;
discordConfig: DiscordConfig;
@@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: {
const buttons = choices.map(
(choice) =>
new DiscordCommandArgButton({
- label: choice,
+ label: choice.label,
customId: buildDiscordCommandArgCustomId({
command: commandLabel,
arg: menu.arg.name,
- value: choice,
+ value: choice.value,
userId,
}),
cfg: params.cfg,
diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts
new file mode 100644
index 000000000..1ec144ba1
--- /dev/null
+++ b/src/infra/unhandled-rejections.test.ts
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "vitest";
+
+import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js";
+
+describe("isAbortError", () => {
+ it("returns true for error with name AbortError", () => {
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it('returns true for error with "This operation was aborted" message', () => {
+ const error = new Error("This operation was aborted");
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it("returns true for undici-style AbortError", () => {
+ // Node's undici throws errors with this exact message
+ const error = Object.assign(new Error("This operation was aborted"), { name: "AbortError" });
+ expect(isAbortError(error)).toBe(true);
+ });
+
+ it("returns true for object with AbortError name", () => {
+ expect(isAbortError({ name: "AbortError", message: "test" })).toBe(true);
+ });
+
+ it("returns false for regular errors", () => {
+ expect(isAbortError(new Error("Something went wrong"))).toBe(false);
+ expect(isAbortError(new TypeError("Cannot read property"))).toBe(false);
+ expect(isAbortError(new RangeError("Invalid array length"))).toBe(false);
+ });
+
+ it("returns false for errors with similar but different messages", () => {
+ expect(isAbortError(new Error("Operation aborted"))).toBe(false);
+ expect(isAbortError(new Error("aborted"))).toBe(false);
+ expect(isAbortError(new Error("Request was aborted"))).toBe(false);
+ });
+
+ it("returns false for null and undefined", () => {
+ expect(isAbortError(null)).toBe(false);
+ expect(isAbortError(undefined)).toBe(false);
+ });
+
+ it("returns false for non-error values", () => {
+ expect(isAbortError("string error")).toBe(false);
+ expect(isAbortError(42)).toBe(false);
+ });
+
+ it("returns false for plain objects without AbortError name", () => {
+ expect(isAbortError({ message: "plain object" })).toBe(false);
+ });
+});
+
+describe("isTransientNetworkError", () => {
+ it("returns true for errors with transient network codes", () => {
+ const codes = [
+ "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",
+ ];
+
+ for (const code of codes) {
+ const error = Object.assign(new Error("test"), { code });
+ expect(isTransientNetworkError(error), `code: ${code}`).toBe(true);
+ }
+ });
+
+ it('returns true for TypeError with "fetch failed" message', () => {
+ const error = new TypeError("fetch failed");
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for fetch failed with network cause", () => {
+ const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" });
+ const error = Object.assign(new TypeError("fetch failed"), { cause });
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for nested cause chain with network error", () => {
+ const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" });
+ const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause });
+ const error = Object.assign(new TypeError("fetch failed"), { cause: outerCause });
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns true for AggregateError containing network errors", () => {
+ const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" });
+ const error = new AggregateError([networkError], "Multiple errors");
+ expect(isTransientNetworkError(error)).toBe(true);
+ });
+
+ it("returns false for regular errors without network codes", () => {
+ expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false);
+ expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false);
+ expect(isTransientNetworkError(new RangeError("Invalid array length"))).toBe(false);
+ });
+
+ it("returns false for errors with non-network codes", () => {
+ const error = Object.assign(new Error("test"), { code: "INVALID_CONFIG" });
+ expect(isTransientNetworkError(error)).toBe(false);
+ });
+
+ it("returns false for null and undefined", () => {
+ expect(isTransientNetworkError(null)).toBe(false);
+ expect(isTransientNetworkError(undefined)).toBe(false);
+ });
+
+ it("returns false for non-error values", () => {
+ expect(isTransientNetworkError("string error")).toBe(false);
+ expect(isTransientNetworkError(42)).toBe(false);
+ expect(isTransientNetworkError({ message: "plain object" })).toBe(false);
+ });
+
+ it("returns false for AggregateError with only non-network errors", () => {
+ const error = new AggregateError([new Error("regular error")], "Multiple errors");
+ expect(isTransientNetworkError(error)).toBe(false);
+ });
+});
diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts
index ac7ac91d5..86e80e9a3 100644
--- a/src/infra/unhandled-rejections.ts
+++ b/src/infra/unhandled-rejections.ts
@@ -1,11 +1,88 @@
import process from "node:process";
-import { formatErrorMessage, formatUncaughtError } from "./errors.js";
+import { formatUncaughtError } from "./errors.js";
type UnhandledRejectionHandler = (reason: unknown) => boolean;
const handlers = new Set();
+/**
+ * Checks if an error is an AbortError.
+ * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash.
+ */
+export function isAbortError(err: unknown): boolean {
+ if (!err || typeof err !== "object") return false;
+ const name = "name" in err ? String(err.name) : "";
+ if (name === "AbortError") return true;
+ // Check for "This operation was aborted" message from Node's undici
+ const message = "message" in err && typeof err.message === "string" ? err.message : "";
+ if (message === "This operation was aborted") return true;
+ 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 getErrorCause(err: unknown): unknown {
+ if (!err || typeof err !== "object") return undefined;
+ return (err as { cause?: unknown }).cause;
+}
+
+/**
+ * Checks if an error is a transient network error that shouldn't crash the gateway.
+ * These are typically temporary connectivity issues that will resolve on their own.
+ */
+export function isTransientNetworkError(err: unknown): boolean {
+ if (!err) return false;
+
+ // Check the error itself
+ const code = getErrorCode(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;
+ }
+
+ // Check the cause chain recursively
+ const cause = getErrorCause(err);
+ if (cause && cause !== err) {
+ return isTransientNetworkError(cause);
+ }
+
+ // AggregateError may wrap multiple causes
+ if (err instanceof AggregateError && err.errors?.length) {
+ return err.errors.some((e) => isTransientNetworkError(e));
+ }
+
+ return false;
+}
+
export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void {
handlers.add(handler);
return () => {
@@ -13,36 +90,6 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan
};
}
-/**
- * Check if an error is a recoverable/transient error that shouldn't crash the process.
- * These include network errors and abort signals during shutdown.
- */
-function isRecoverableError(reason: unknown): boolean {
- if (!reason) return false;
-
- // Check error name for AbortError
- if (reason instanceof Error && reason.name === "AbortError") {
- return true;
- }
-
- const message = reason instanceof Error ? reason.message : formatErrorMessage(reason);
- const lowerMessage = message.toLowerCase();
- return (
- lowerMessage.includes("fetch failed") ||
- lowerMessage.includes("network request") ||
- lowerMessage.includes("econnrefused") ||
- lowerMessage.includes("econnreset") ||
- lowerMessage.includes("etimedout") ||
- lowerMessage.includes("socket hang up") ||
- lowerMessage.includes("enotfound") ||
- lowerMessage.includes("network error") ||
- lowerMessage.includes("getaddrinfo") ||
- lowerMessage.includes("client network socket disconnected") ||
- lowerMessage.includes("this operation was aborted") ||
- lowerMessage.includes("aborted")
- );
-}
-
export function isUnhandledRejectionHandled(reason: unknown): boolean {
for (const handler of handlers) {
try {
@@ -61,9 +108,17 @@ export function installUnhandledRejectionHandler(): void {
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) return;
- // Don't crash on recoverable/transient errors - log them and continue
- if (isRecoverableError(reason)) {
- console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason));
+ // AbortError is typically an intentional cancellation (e.g., during shutdown)
+ // Log it but don't crash - these are expected during graceful shutdown
+ if (isAbortError(reason)) {
+ console.warn("[clawdbot] Suppressed AbortError:", formatUncaughtError(reason));
+ return;
+ }
+
+ // Transient network errors (fetch failed, connection reset, etc.) shouldn't crash
+ // These are temporary connectivity issues that will resolve on their own
+ if (isTransientNetworkError(reason)) {
+ console.error("[clawdbot] Network error (non-fatal):", formatUncaughtError(reason));
return;
}
diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts
index d1c2a00ca..ae6d61106 100644
--- a/src/slack/monitor/slash.ts
+++ b/src/slack/monitor/slash.ts
@@ -103,7 +103,7 @@ function buildSlackCommandArgMenuBlocks(params: {
title: string;
command: string;
arg: string;
- choices: string[];
+ choices: Array<{ value: string; label: string }>;
userId: string;
}) {
const rows = chunkItems(params.choices, 5).map((choices) => ({
@@ -111,11 +111,11 @@ function buildSlackCommandArgMenuBlocks(params: {
elements: choices.map((choice) => ({
type: "button",
action_id: SLACK_COMMAND_ARG_ACTION_ID,
- text: { type: "plain_text", text: choice },
+ text: { type: "plain_text", text: choice.label },
value: encodeSlackCommandArgValue({
command: params.command,
arg: params.arg,
- value: choice,
+ value: choice.value,
userId: params.userId,
}),
})),
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index c33f1e18e..e9d287d0d 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -366,10 +366,10 @@ export const registerTelegramNativeCommands = ({
rows.push(
slice.map((choice) => {
const args: CommandArgs = {
- values: { [menu.arg.name]: choice },
+ values: { [menu.arg.name]: choice.value },
};
return {
- text: choice,
+ text: choice.label,
callback_data: buildCommandTextFromArgs(commandDefinition, args),
};
}),
diff --git a/src/tts/tts.ts b/src/tts/tts.ts
index 847876d04..9507c5535 100644
--- a/src/tts/tts.ts
+++ b/src/tts/tts.ts
@@ -40,7 +40,7 @@ import { resolveModel } from "../agents/pi-embedded-runner/model.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
-const DEFAULT_MAX_TEXT_LENGTH = 4000;
+const DEFAULT_MAX_TEXT_LENGTH = 4096;
const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
@@ -1386,32 +1386,34 @@ export async function maybeApplyTtsToPayload(params: {
if (textForAudio.length > maxLength) {
if (!isSummarizationEnabled(prefsPath)) {
+ // Truncate text when summarization is disabled
logVerbose(
- `TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
+ `TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`,
);
- return nextPayload;
- }
-
- try {
- const summary = await summarizeText({
- text: textForAudio,
- targetLength: maxLength,
- cfg: params.cfg,
- config,
- timeoutMs: config.timeoutMs,
- });
- textForAudio = summary.summary;
- wasSummarized = true;
- if (textForAudio.length > config.maxTextLength) {
- logVerbose(
- `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
- );
- textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
+ textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
+ } else {
+ // Summarize text when enabled
+ try {
+ const summary = await summarizeText({
+ text: textForAudio,
+ targetLength: maxLength,
+ cfg: params.cfg,
+ config,
+ timeoutMs: config.timeoutMs,
+ });
+ textForAudio = summary.summary;
+ wasSummarized = true;
+ if (textForAudio.length > config.maxTextLength) {
+ logVerbose(
+ `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`,
+ );
+ textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`;
+ }
+ } catch (err) {
+ const error = err as Error;
+ logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`);
+ textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`;
}
- } catch (err) {
- const error = err as Error;
- logVerbose(`TTS: summarization failed: ${error.message}`);
- return nextPayload;
}
}
@@ -1436,12 +1438,12 @@ export async function maybeApplyTtsToPayload(params: {
const channelId = resolveChannelId(params.channel);
const shouldVoice = channelId === "telegram" && result.voiceCompatible === true;
-
- return {
+ const finalPayload = {
...nextPayload,
mediaUrl: result.audioPath,
audioAsVoice: shouldVoice || params.payload.audioAsVoice,
};
+ return finalPayload;
}
lastTtsAttempt = {