From bf6df6d6b72f2105443d7d5aa648f782e0793ee2 Mon Sep 17 00:00:00 2001
From: Dominic Damoah
- Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).
+ Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).
Send a message, get an agent response — from your pocket.
` or use allowlists.
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index b1102c47d..5786c696e 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Stream tool calls + live tool output cards in Chat (agent events)
-- Channels: WhatsApp/Telegram status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
+- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
diff --git a/extensions/mattermost/clawdbot.plugin.json b/extensions/mattermost/clawdbot.plugin.json
new file mode 100644
index 000000000..ddb3f8160
--- /dev/null
+++ b/extensions/mattermost/clawdbot.plugin.json
@@ -0,0 +1,11 @@
+{
+ "id": "mattermost",
+ "channels": [
+ "mattermost"
+ ],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts
new file mode 100644
index 000000000..f3bf17ad5
--- /dev/null
+++ b/extensions/mattermost/index.ts
@@ -0,0 +1,18 @@
+import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
+import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
+
+import { mattermostPlugin } from "./src/channel.js";
+import { setMattermostRuntime } from "./src/runtime.js";
+
+const plugin = {
+ id: "mattermost",
+ name: "Mattermost",
+ description: "Mattermost channel plugin",
+ configSchema: emptyPluginConfigSchema(),
+ register(api: ClawdbotPluginApi) {
+ setMattermostRuntime(api.runtime);
+ api.registerChannel({ plugin: mattermostPlugin });
+ },
+};
+
+export default plugin;
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
new file mode 100644
index 000000000..8ba462f45
--- /dev/null
+++ b/extensions/mattermost/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@clawdbot/mattermost",
+ "version": "2026.1.20-2",
+ "type": "module",
+ "description": "Clawdbot Mattermost channel plugin",
+ "clawdbot": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
new file mode 100644
index 000000000..840772a17
--- /dev/null
+++ b/extensions/mattermost/src/channel.ts
@@ -0,0 +1,270 @@
+import {
+ applyAccountNameToChannelSection,
+ buildChannelConfigSchema,
+ DEFAULT_ACCOUNT_ID,
+ deleteAccountFromConfigSection,
+ getChatChannelMeta,
+ listMattermostAccountIds,
+ looksLikeMattermostTargetId,
+ migrateBaseNameToDefaultAccount,
+ normalizeAccountId,
+ normalizeMattermostBaseUrl,
+ normalizeMattermostMessagingTarget,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ resolveMattermostGroupRequireMention,
+ setAccountEnabledInConfigSection,
+ mattermostOnboardingAdapter,
+ MattermostConfigSchema,
+ type ChannelPlugin,
+ type ResolvedMattermostAccount,
+} from "clawdbot/plugin-sdk";
+
+import { getMattermostRuntime } from "./runtime.js";
+
+const meta = getChatChannelMeta("mattermost");
+
+export const mattermostPlugin: ChannelPlugin = {
+ id: "mattermost",
+ meta: {
+ ...meta,
+ },
+ onboarding: mattermostOnboardingAdapter,
+ capabilities: {
+ chatTypes: ["direct", "channel", "group", "thread"],
+ threads: true,
+ media: true,
+ },
+ streaming: {
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
+ },
+ reload: { configPrefixes: ["channels.mattermost"] },
+ configSchema: buildChannelConfigSchema(MattermostConfigSchema),
+ config: {
+ listAccountIds: (cfg) => listMattermostAccountIds(cfg),
+ resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
+ defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
+ setAccountEnabledInConfigSection({
+ cfg,
+ sectionKey: "mattermost",
+ accountId,
+ enabled,
+ allowTopLevel: true,
+ }),
+ deleteAccount: ({ cfg, accountId }) =>
+ deleteAccountFromConfigSection({
+ cfg,
+ sectionKey: "mattermost",
+ accountId,
+ clearBaseFields: ["botToken", "baseUrl", "name"],
+ }),
+ isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
+ describeAccount: (account) => ({
+ accountId: account.accountId,
+ name: account.name,
+ enabled: account.enabled,
+ configured: Boolean(account.botToken && account.baseUrl),
+ botTokenSource: account.botTokenSource,
+ baseUrl: account.baseUrl,
+ }),
+ },
+ groups: {
+ resolveRequireMention: resolveMattermostGroupRequireMention,
+ },
+ messaging: {
+ normalizeTarget: normalizeMattermostMessagingTarget,
+ targetResolver: {
+ looksLikeId: looksLikeMattermostTargetId,
+ hint: "",
+ },
+ },
+ outbound: {
+ deliveryMode: "direct",
+ chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
+ textChunkLimit: 4000,
+ resolveTarget: ({ to }) => {
+ const trimmed = to?.trim();
+ if (!trimmed) {
+ return {
+ ok: false,
+ error: new Error(
+ "Delivering to Mattermost requires --to ",
+ ),
+ };
+ }
+ return { ok: true, to: trimmed };
+ },
+ sendText: async ({ to, text, accountId, deps, replyToId }) => {
+ const send =
+ deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const result = await send(to, text, {
+ accountId: accountId ?? undefined,
+ replyToId: replyToId ?? undefined,
+ });
+ return { channel: "mattermost", ...result };
+ },
+ sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
+ const send =
+ deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const result = await send(to, text, {
+ accountId: accountId ?? undefined,
+ mediaUrl,
+ replyToId: replyToId ?? undefined,
+ });
+ return { channel: "mattermost", ...result };
+ },
+ },
+ status: {
+ defaultRuntime: {
+ accountId: DEFAULT_ACCOUNT_ID,
+ running: false,
+ connected: false,
+ lastConnectedAt: null,
+ lastDisconnect: null,
+ lastStartAt: null,
+ lastStopAt: null,
+ lastError: null,
+ },
+ buildChannelSummary: ({ snapshot }) => ({
+ configured: snapshot.configured ?? false,
+ botTokenSource: snapshot.botTokenSource ?? "none",
+ running: snapshot.running ?? false,
+ connected: snapshot.connected ?? false,
+ lastStartAt: snapshot.lastStartAt ?? null,
+ lastStopAt: snapshot.lastStopAt ?? null,
+ lastError: snapshot.lastError ?? null,
+ baseUrl: snapshot.baseUrl ?? null,
+ probe: snapshot.probe,
+ lastProbeAt: snapshot.lastProbeAt ?? null,
+ }),
+ probeAccount: async ({ account, timeoutMs }) => {
+ const token = account.botToken?.trim();
+ const baseUrl = account.baseUrl?.trim();
+ if (!token || !baseUrl) {
+ return { ok: false, error: "bot token or baseUrl missing" };
+ }
+ return await getMattermostRuntime().channel.mattermost.probeMattermost(
+ baseUrl,
+ token,
+ timeoutMs,
+ );
+ },
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
+ accountId: account.accountId,
+ name: account.name,
+ enabled: account.enabled,
+ configured: Boolean(account.botToken && account.baseUrl),
+ botTokenSource: account.botTokenSource,
+ baseUrl: account.baseUrl,
+ running: runtime?.running ?? false,
+ connected: runtime?.connected ?? false,
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
+ lastDisconnect: runtime?.lastDisconnect ?? null,
+ lastStartAt: runtime?.lastStartAt ?? null,
+ lastStopAt: runtime?.lastStopAt ?? null,
+ lastError: runtime?.lastError ?? null,
+ probe,
+ lastInboundAt: runtime?.lastInboundAt ?? null,
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
+ }),
+ },
+ setup: {
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+ applyAccountName: ({ cfg, accountId, name }) =>
+ applyAccountNameToChannelSection({
+ cfg,
+ channelKey: "mattermost",
+ accountId,
+ name,
+ }),
+ validateInput: ({ accountId, input }) => {
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
+ return "Mattermost env vars can only be used for the default account.";
+ }
+ const token = input.botToken ?? input.token;
+ const baseUrl = input.httpUrl;
+ if (!input.useEnv && (!token || !baseUrl)) {
+ return "Mattermost requires --bot-token and --http-url (or --use-env).";
+ }
+ if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
+ return "Mattermost --http-url must include a valid base URL.";
+ }
+ return null;
+ },
+ applyAccountConfig: ({ cfg, accountId, input }) => {
+ const token = input.botToken ?? input.token;
+ const baseUrl = input.httpUrl?.trim();
+ const namedConfig = applyAccountNameToChannelSection({
+ cfg,
+ channelKey: "mattermost",
+ accountId,
+ name: input.name,
+ });
+ const next =
+ accountId !== DEFAULT_ACCOUNT_ID
+ ? migrateBaseNameToDefaultAccount({
+ cfg: namedConfig,
+ channelKey: "mattermost",
+ })
+ : namedConfig;
+ if (accountId === DEFAULT_ACCOUNT_ID) {
+ return {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ ...(input.useEnv
+ ? {}
+ : {
+ ...(token ? { botToken: token } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ }),
+ },
+ },
+ };
+ }
+ return {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ accounts: {
+ ...next.channels?.mattermost?.accounts,
+ [accountId]: {
+ ...next.channels?.mattermost?.accounts?.[accountId],
+ enabled: true,
+ ...(token ? { botToken: token } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ },
+ },
+ };
+ },
+ },
+ gateway: {
+ startAccount: async (ctx) => {
+ const account = ctx.account;
+ ctx.setStatus({
+ accountId: account.accountId,
+ baseUrl: account.baseUrl,
+ botTokenSource: account.botTokenSource,
+ });
+ ctx.log?.info(`[${account.accountId}] starting channel`);
+ return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
+ botToken: account.botToken ?? undefined,
+ baseUrl: account.baseUrl ?? undefined,
+ accountId: account.accountId,
+ config: ctx.cfg,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ });
+ },
+ },
+};
diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts
new file mode 100644
index 000000000..3d0ad283f
--- /dev/null
+++ b/extensions/mattermost/src/runtime.ts
@@ -0,0 +1,14 @@
+import type { PluginRuntime } from "clawdbot/plugin-sdk";
+
+let runtime: PluginRuntime | null = null;
+
+export function setMattermostRuntime(next: PluginRuntime) {
+ runtime = next;
+}
+
+export function getMattermostRuntime(): PluginRuntime {
+ if (!runtime) {
+ throw new Error("Mattermost runtime not initialized");
+ }
+ return runtime;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c0830488b..7c2c788b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -324,6 +324,8 @@ importers:
specifier: ^11.10.6
version: 11.10.6
+ extensions/mattermost: {}
+
extensions/memory-core:
dependencies:
clawdbot:
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index aa2281de6..5a5e1b4e1 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -157,7 +157,10 @@ export async function runPreparedReply(
const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group";
- const wasMentioned = ctx.WasMentioned === true;
+ const originatingChannel =
+ (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
+ const wasMentioned =
+ ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
diff --git a/src/channels/dock.ts b/src/channels/dock.ts
index 92199a0f2..e6fd3150a 100644
--- a/src/channels/dock.ts
+++ b/src/channels/dock.ts
@@ -11,6 +11,7 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
+ resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -235,6 +236,30 @@ const DOCKS: Record = {
},
},
},
+ mattermost: {
+ id: "mattermost",
+ capabilities: {
+ chatTypes: ["direct", "channel", "group", "thread"],
+ media: true,
+ threads: true,
+ },
+ outbound: { textChunkLimit: 4000 },
+ streaming: {
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
+ },
+ groups: {
+ resolveRequireMention: resolveMattermostGroupRequireMention,
+ },
+ threading: {
+ buildToolContext: ({ context, hasRepliedRef }) => ({
+ currentChannelId: context.To?.startsWith("channel:")
+ ? context.To.slice("channel:".length)
+ : undefined,
+ currentThreadTs: context.ReplyToId,
+ hasRepliedRef,
+ }),
+ },
+ },
signal: {
id: "signal",
capabilities: {
diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts
index 79dfa0320..bb7a111e8 100644
--- a/src/channels/plugins/group-mentions.ts
+++ b/src/channels/plugins/group-mentions.ts
@@ -1,6 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
+import { resolveMattermostAccount } from "../../mattermost/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -184,6 +185,15 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
return true;
}
+export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean {
+ const account = resolveMattermostAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ if (typeof account.requireMention === "boolean") return account.requireMention;
+ return true;
+}
+
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
diff --git a/src/channels/plugins/normalize/mattermost.ts b/src/channels/plugins/normalize/mattermost.ts
new file mode 100644
index 000000000..80366420f
--- /dev/null
+++ b/src/channels/plugins/normalize/mattermost.ts
@@ -0,0 +1,38 @@
+export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
+ const trimmed = raw.trim();
+ if (!trimmed) return undefined;
+ const lower = trimmed.toLowerCase();
+ if (lower.startsWith("channel:")) {
+ const id = trimmed.slice("channel:".length).trim();
+ return id ? `channel:${id}` : undefined;
+ }
+ if (lower.startsWith("group:")) {
+ const id = trimmed.slice("group:".length).trim();
+ return id ? `channel:${id}` : undefined;
+ }
+ if (lower.startsWith("user:")) {
+ const id = trimmed.slice("user:".length).trim();
+ return id ? `user:${id}` : undefined;
+ }
+ if (lower.startsWith("mattermost:")) {
+ const id = trimmed.slice("mattermost:".length).trim();
+ return id ? `user:${id}` : undefined;
+ }
+ if (trimmed.startsWith("@")) {
+ const id = trimmed.slice(1).trim();
+ return id ? `user:${id}` : undefined;
+ }
+ if (trimmed.startsWith("#")) {
+ const id = trimmed.slice(1).trim();
+ return id ? `channel:${id}` : undefined;
+ }
+ return `channel:${trimmed}`;
+}
+
+export function looksLikeMattermostTargetId(raw: string): boolean {
+ const trimmed = raw.trim();
+ if (!trimmed) return false;
+ if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
+ if (/^[@#]/.test(trimmed)) return true;
+ return /^[a-z0-9]{8,}$/i.test(trimmed);
+}
diff --git a/src/channels/plugins/onboarding/mattermost.ts b/src/channels/plugins/onboarding/mattermost.ts
new file mode 100644
index 000000000..3c7ffe2db
--- /dev/null
+++ b/src/channels/plugins/onboarding/mattermost.ts
@@ -0,0 +1,189 @@
+import type { ClawdbotConfig } from "../../../config/config.js";
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+} from "../../../mattermost/accounts.js";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
+import { formatDocsLink } from "../../../terminal/links.js";
+import type { WizardPrompter } from "../../../wizard/prompts.js";
+import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
+import { promptAccountId } from "./helpers.js";
+
+const channel = "mattermost" as const;
+
+async function noteMattermostSetup(prompter: WizardPrompter): Promise {
+ await prompter.note(
+ [
+ "1) Mattermost System Console -> Integrations -> Bot Accounts",
+ "2) Create a bot + copy its token",
+ "3) Use your server base URL (e.g., https://chat.example.com)",
+ "Tip: the bot must be a member of any channel you want it to monitor.",
+ `Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`,
+ ].join("\n"),
+ "Mattermost bot token",
+ );
+}
+
+export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
+ channel,
+ getStatus: async ({ cfg }) => {
+ const configured = listMattermostAccountIds(cfg).some((accountId) => {
+ const account = resolveMattermostAccount({ cfg, accountId });
+ return Boolean(account.botToken && account.baseUrl);
+ });
+ return {
+ channel,
+ configured,
+ statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
+ selectionHint: configured ? "configured" : "needs setup",
+ quickstartScore: configured ? 2 : 1,
+ };
+ },
+ configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
+ const override = accountOverrides.mattermost?.trim();
+ const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
+ let accountId = override ? normalizeAccountId(override) : defaultAccountId;
+ if (shouldPromptAccountIds && !override) {
+ accountId = await promptAccountId({
+ cfg,
+ prompter,
+ label: "Mattermost",
+ currentId: accountId,
+ listAccountIds: listMattermostAccountIds,
+ defaultAccountId,
+ });
+ }
+
+ let next = cfg;
+ const resolvedAccount = resolveMattermostAccount({
+ cfg: next,
+ accountId,
+ });
+ const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
+ const canUseEnv =
+ allowEnv &&
+ Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
+ Boolean(process.env.MATTERMOST_URL?.trim());
+ const hasConfigValues =
+ Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
+
+ let botToken: string | null = null;
+ let baseUrl: string | null = null;
+
+ if (!accountConfigured) {
+ await noteMattermostSetup(prompter);
+ }
+
+ if (canUseEnv && !hasConfigValues) {
+ const keepEnv = await prompter.confirm({
+ message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
+ initialValue: true,
+ });
+ if (keepEnv) {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ },
+ },
+ };
+ } else {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+ } else if (accountConfigured) {
+ const keep = await prompter.confirm({
+ message: "Mattermost credentials already configured. Keep them?",
+ initialValue: true,
+ });
+ if (!keep) {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+ } else {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+
+ if (botToken || baseUrl) {
+ if (accountId === DEFAULT_ACCOUNT_ID) {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ ...(botToken ? { botToken } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ };
+ } else {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ accounts: {
+ ...next.channels?.mattermost?.accounts,
+ [accountId]: {
+ ...next.channels?.mattermost?.accounts?.[accountId],
+ enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
+ ...(botToken ? { botToken } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ },
+ },
+ };
+ }
+ }
+
+ return { cfg: next, accountId };
+ },
+ disable: (cfg: ClawdbotConfig) => ({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ mattermost: { ...cfg.channels?.mattermost, enabled: false },
+ },
+ }),
+};
diff --git a/src/channels/registry.ts b/src/channels/registry.ts
index 52e7a5f01..25fb13502 100644
--- a/src/channels/registry.ts
+++ b/src/channels/registry.ts
@@ -9,6 +9,7 @@ export const CHAT_CHANNEL_ORDER = [
"whatsapp",
"discord",
"slack",
+ "mattermost",
"signal",
"imessage",
] as const;
@@ -67,6 +68,16 @@ const CHAT_CHANNEL_META: Record = {
blurb: "supported (Socket Mode).",
systemImage: "number",
},
+ mattermost: {
+ id: "mattermost",
+ label: "Mattermost",
+ selectionLabel: "Mattermost (Bot Token)",
+ detailLabel: "Mattermost Bot",
+ docsPath: "/channels/mattermost",
+ docsLabel: "mattermost",
+ blurb: "self-hosted Slack-style chat (bot token + URL).",
+ systemImage: "bubble.left.and.bubble.right",
+ },
signal: {
id: "signal",
label: "Signal",
diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts
index 7394fa30f..820b53bf0 100644
--- a/src/commands/channels/resolve.ts
+++ b/src/commands/channels/resolve.ts
@@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
if (!trimmed) return "group";
if (trimmed.startsWith("@")) return "user";
if (/^<@!?/.test(trimmed)) return "user";
- if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
+ if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
return "user";
}
return "group";
diff --git a/src/config/io.ts b/src/config/io.ts
index d275d3185..34b534285 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -52,6 +52,8 @@ const SHELL_ENV_EXPECTED_KEYS = [
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
+ "MATTERMOST_BOT_TOKEN",
+ "MATTERMOST_URL",
"CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD",
];
diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts
index d1d0a57e7..8658b0ece 100644
--- a/src/config/legacy.migrations.part-1.ts
+++ b/src/config/legacy.migrations.part-1.ts
@@ -124,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
"telegram",
"discord",
"slack",
+ "mattermost",
"signal",
"imessage",
"msteams",
diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts
index 1ec76bc79..388083ae7 100644
--- a/src/config/legacy.rules.ts
+++ b/src/config/legacy.rules.ts
@@ -17,6 +17,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
path: ["slack"],
message: "slack config moved to channels.slack (auto-migrated on load).",
},
+ {
+ path: ["mattermost"],
+ message: "mattermost config moved to channels.mattermost (auto-migrated on load).",
+ },
{
path: ["signal"],
message: "signal config moved to channels.signal (auto-migrated on load).",
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 1ba527439..21f461ec0 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -272,6 +272,7 @@ const FIELD_LABELS: Record = {
"channels.telegram.customCommands": "Telegram Custom Commands",
"channels.discord": "Discord",
"channels.slack": "Slack",
+ "channels.mattermost": "Mattermost",
"channels.signal": "Signal",
"channels.imessage": "iMessage",
"channels.bluebubbles": "BlueBubbles",
@@ -309,6 +310,11 @@ const FIELD_LABELS: Record = {
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
"channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
+ "channels.mattermost.botToken": "Mattermost Bot Token",
+ "channels.mattermost.baseUrl": "Mattermost Base URL",
+ "channels.mattermost.chatmode": "Mattermost Chat Mode",
+ "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
+ "channels.mattermost.requireMention": "Mattermost Require Mention",
"channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path",
"plugins.enabled": "Enable Plugins",
@@ -415,6 +421,15 @@ const FIELD_HELP: Record = {
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
+ "channels.mattermost.botToken":
+ "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
+ "channels.mattermost.baseUrl":
+ "Base URL for your Mattermost server (e.g., https://chat.example.com).",
+ "channels.mattermost.chatmode":
+ 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").',
+ "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).',
+ "channels.mattermost.requireMention":
+ "Require @mention in channels before responding (default: true).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
"auth.cooldowns.billingBackoffHours":
@@ -532,6 +547,8 @@ const FIELD_HELP: Record = {
"Allow Telegram to write config in response to channel events/commands (default: true).",
"channels.slack.configWrites":
"Allow Slack to write config in response to channel events/commands (default: true).",
+ "channels.mattermost.configWrites":
+ "Allow Mattermost to write config in response to channel events/commands (default: true).",
"channels.discord.configWrites":
"Allow Discord to write config in response to channel events/commands (default: true).",
"channels.whatsapp.configWrites":
@@ -606,6 +623,7 @@ const FIELD_PLACEHOLDERS: Record = {
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
"gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/clawdbot",
+ "channels.mattermost.baseUrl": "https://chat.example.com",
};
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts
index 85eff97f2..10066bd75 100644
--- a/src/config/types.agent-defaults.ts
+++ b/src/config/types.agent-defaults.ts
@@ -162,13 +162,14 @@ export type AgentDefaultsConfig = {
every?: string;
/** Heartbeat model override (provider/model). */
model?: string;
- /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
+ /** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
+ | "mattermost"
| "msteams"
| "signal"
| "imessage"
diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts
index ac98e20de..19ac014dd 100644
--- a/src/config/types.channels.ts
+++ b/src/config/types.channels.ts
@@ -1,5 +1,6 @@
import type { DiscordConfig } from "./types.discord.js";
import type { IMessageConfig } from "./types.imessage.js";
+import type { MattermostConfig } from "./types.mattermost.js";
import type { MSTeamsConfig } from "./types.msteams.js";
import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
@@ -17,6 +18,7 @@ export type ChannelsConfig = {
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
+ mattermost?: MattermostConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts
index 03e9250b2..2a5bf0f2f 100644
--- a/src/config/types.hooks.ts
+++ b/src/config/types.hooks.ts
@@ -24,6 +24,7 @@ export type HookMappingConfig = {
| "telegram"
| "discord"
| "slack"
+ | "mattermost"
| "signal"
| "imessage"
| "msteams";
diff --git a/src/config/types.mattermost.ts b/src/config/types.mattermost.ts
new file mode 100644
index 000000000..b87bdfabe
--- /dev/null
+++ b/src/config/types.mattermost.ts
@@ -0,0 +1,40 @@
+import type { BlockStreamingCoalesceConfig } from "./types.base.js";
+
+export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
+
+export type MattermostAccountConfig = {
+ /** Optional display name for this account (used in CLI/UI lists). */
+ name?: string;
+ /** Optional provider capability tags used for agent/runtime guidance. */
+ capabilities?: string[];
+ /** Allow channel-initiated config writes (default: true). */
+ configWrites?: boolean;
+ /** If false, do not start this Mattermost account. Default: true. */
+ enabled?: boolean;
+ /** Bot token for Mattermost. */
+ botToken?: string;
+ /** Base URL for the Mattermost server (e.g., https://chat.example.com). */
+ baseUrl?: string;
+ /**
+ * Controls when channel messages trigger replies.
+ * - "oncall": only respond when mentioned
+ * - "onmessage": respond to every channel message
+ * - "onchar": respond when a trigger character prefixes the message
+ */
+ chatmode?: MattermostChatMode;
+ /** Prefix characters that trigger onchar mode (default: [">", "!"]). */
+ oncharPrefixes?: string[];
+ /** Require @mention to respond in channels. Default: true. */
+ requireMention?: boolean;
+ /** Outbound text chunk size (chars). Default: 4000. */
+ textChunkLimit?: number;
+ /** Disable block streaming for this account. */
+ blockStreaming?: boolean;
+ /** Merge streamed block replies before sending. */
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+};
+
+export type MattermostConfig = {
+ /** Optional per-account Mattermost configuration (multi-account). */
+ accounts?: Record;
+} & MattermostAccountConfig;
diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts
index 691ca617a..fc4146bc7 100644
--- a/src/config/types.messages.ts
+++ b/src/config/types.messages.ts
@@ -22,6 +22,7 @@ export type InboundDebounceByProvider = {
telegram?: number;
discord?: number;
slack?: number;
+ mattermost?: number;
signal?: number;
imessage?: number;
msteams?: number;
diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts
index 0afeb5232..6289e7c56 100644
--- a/src/config/types.queue.ts
+++ b/src/config/types.queue.ts
@@ -13,6 +13,7 @@ export type QueueModeByProvider = {
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
+ mattermost?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;
diff --git a/src/config/types.ts b/src/config/types.ts
index 368618262..46e79eaca 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -14,6 +14,7 @@ export * from "./types.hooks.js";
export * from "./types.imessage.js";
export * from "./types.messages.js";
export * from "./types.models.js";
+export * from "./types.mattermost.js";
export * from "./types.msteams.js";
export * from "./types.plugins.js";
export * from "./types.queue.js";
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 40dea6eb4..774645a14 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -20,6 +20,7 @@ export const HeartbeatSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
+ z.literal("mattermost"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts
index 6e7b34b0d..1d6612aae 100644
--- a/src/config/zod-schema.core.ts
+++ b/src/config/zod-schema.core.ts
@@ -208,6 +208,7 @@ export const QueueModeBySurfaceSchema = z
telegram: QueueModeSchema.optional(),
discord: QueueModeSchema.optional(),
slack: QueueModeSchema.optional(),
+ mattermost: QueueModeSchema.optional(),
signal: QueueModeSchema.optional(),
imessage: QueueModeSchema.optional(),
msteams: QueueModeSchema.optional(),
@@ -222,6 +223,7 @@ export const DebounceMsBySurfaceSchema = z
telegram: z.number().int().nonnegative().optional(),
discord: z.number().int().nonnegative().optional(),
slack: z.number().int().nonnegative().optional(),
+ mattermost: z.number().int().nonnegative().optional(),
signal: z.number().int().nonnegative().optional(),
imessage: z.number().int().nonnegative().optional(),
msteams: z.number().int().nonnegative().optional(),
diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts
index 140e861dd..9153aa130 100644
--- a/src/config/zod-schema.hooks.ts
+++ b/src/config/zod-schema.hooks.ts
@@ -23,6 +23,7 @@ export const HookMappingSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
+ z.literal("mattermost"),
z.literal("signal"),
z.literal("imessage"),
z.literal("msteams"),
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 906ef5433..200ff18c8 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -367,6 +367,27 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
}
});
+export const MattermostAccountSchema = z
+ .object({
+ name: z.string().optional(),
+ capabilities: z.array(z.string()).optional(),
+ enabled: z.boolean().optional(),
+ configWrites: z.boolean().optional(),
+ botToken: z.string().optional(),
+ baseUrl: z.string().optional(),
+ chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
+ oncharPrefixes: z.array(z.string()).optional(),
+ requireMention: z.boolean().optional(),
+ textChunkLimit: z.number().int().positive().optional(),
+ blockStreaming: z.boolean().optional(),
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+ })
+ .strict();
+
+export const MattermostConfigSchema = MattermostAccountSchema.extend({
+ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
+});
+
export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(),
diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts
index a58119702..aa5eb7737 100644
--- a/src/config/zod-schema.providers.ts
+++ b/src/config/zod-schema.providers.ts
@@ -4,6 +4,7 @@ import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
IMessageConfigSchema,
+ MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -27,6 +28,7 @@ export const ChannelsSchema = z
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
+ mattermost: MattermostConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
bluebubbles: BlueBubblesConfigSchema.optional(),
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 21fffe807..74caa18c6 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
+import type { sendMessageMattermost } from "../../mattermost/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
@@ -33,6 +34,7 @@ export type OutboundSendDeps = {
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
+ sendMattermost?: typeof sendMessageMattermost;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/mattermost/accounts.ts b/src/mattermost/accounts.ts
new file mode 100644
index 000000000..08ffa2f94
--- /dev/null
+++ b/src/mattermost/accounts.ts
@@ -0,0 +1,114 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
+import { normalizeMattermostBaseUrl } from "./client.js";
+
+export type MattermostTokenSource = "env" | "config" | "none";
+export type MattermostBaseUrlSource = "env" | "config" | "none";
+
+export type ResolvedMattermostAccount = {
+ accountId: string;
+ enabled: boolean;
+ name?: string;
+ botToken?: string;
+ baseUrl?: string;
+ botTokenSource: MattermostTokenSource;
+ baseUrlSource: MattermostBaseUrlSource;
+ config: MattermostAccountConfig;
+ chatmode?: MattermostChatMode;
+ oncharPrefixes?: string[];
+ requireMention?: boolean;
+ textChunkLimit?: number;
+ blockStreaming?: boolean;
+ blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
+};
+
+function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
+ const accounts = cfg.channels?.mattermost?.accounts;
+ if (!accounts || typeof accounts !== "object") return [];
+ return Object.keys(accounts).filter(Boolean);
+}
+
+export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
+ const ids = listConfiguredAccountIds(cfg);
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
+ return ids.sort((a, b) => a.localeCompare(b));
+}
+
+export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
+ const ids = listMattermostAccountIds(cfg);
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
+}
+
+function resolveAccountConfig(
+ cfg: ClawdbotConfig,
+ accountId: string,
+): MattermostAccountConfig | undefined {
+ const accounts = cfg.channels?.mattermost?.accounts;
+ if (!accounts || typeof accounts !== "object") return undefined;
+ return accounts[accountId] as MattermostAccountConfig | undefined;
+}
+
+function mergeMattermostAccountConfig(
+ cfg: ClawdbotConfig,
+ accountId: string,
+): MattermostAccountConfig {
+ const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
+ {}) as MattermostAccountConfig & { accounts?: unknown };
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
+ return { ...base, ...account };
+}
+
+function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
+ if (config.chatmode === "oncall") return true;
+ if (config.chatmode === "onmessage") return false;
+ if (config.chatmode === "onchar") return true;
+ return config.requireMention;
+}
+
+export function resolveMattermostAccount(params: {
+ cfg: ClawdbotConfig;
+ accountId?: string | null;
+}): ResolvedMattermostAccount {
+ const accountId = normalizeAccountId(params.accountId);
+ const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
+ const merged = mergeMattermostAccountConfig(params.cfg, accountId);
+ const accountEnabled = merged.enabled !== false;
+ const enabled = baseEnabled && accountEnabled;
+
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
+ const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
+ const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
+ const configToken = merged.botToken?.trim();
+ const configUrl = merged.baseUrl?.trim();
+ const botToken = configToken || envToken;
+ const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
+ const requireMention = resolveMattermostRequireMention(merged);
+
+ const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
+ const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
+
+ return {
+ accountId,
+ enabled,
+ name: merged.name?.trim() || undefined,
+ botToken,
+ baseUrl,
+ botTokenSource,
+ baseUrlSource,
+ config: merged,
+ chatmode: merged.chatmode,
+ oncharPrefixes: merged.oncharPrefixes,
+ requireMention,
+ textChunkLimit: merged.textChunkLimit,
+ blockStreaming: merged.blockStreaming,
+ blockStreamingCoalesce: merged.blockStreamingCoalesce,
+ };
+}
+
+export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
+ return listMattermostAccountIds(cfg)
+ .map((accountId) => resolveMattermostAccount({ cfg, accountId }))
+ .filter((account) => account.enabled);
+}
diff --git a/src/mattermost/client.ts b/src/mattermost/client.ts
new file mode 100644
index 000000000..6b63f830f
--- /dev/null
+++ b/src/mattermost/client.ts
@@ -0,0 +1,208 @@
+export type MattermostClient = {
+ baseUrl: string;
+ apiBaseUrl: string;
+ token: string;
+ request: (path: string, init?: RequestInit) => Promise;
+};
+
+export type MattermostUser = {
+ id: string;
+ username?: string | null;
+ nickname?: string | null;
+ first_name?: string | null;
+ last_name?: string | null;
+};
+
+export type MattermostChannel = {
+ id: string;
+ name?: string | null;
+ display_name?: string | null;
+ type?: string | null;
+ team_id?: string | null;
+};
+
+export type MattermostPost = {
+ id: string;
+ user_id?: string | null;
+ channel_id?: string | null;
+ message?: string | null;
+ file_ids?: string[] | null;
+ type?: string | null;
+ root_id?: string | null;
+ create_at?: number | null;
+ props?: Record | null;
+};
+
+export type MattermostFileInfo = {
+ id: string;
+ name?: string | null;
+ mime_type?: string | null;
+ size?: number | null;
+};
+
+export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
+ const trimmed = raw?.trim();
+ if (!trimmed) return undefined;
+ const withoutTrailing = trimmed.replace(/\/+$/, "");
+ return withoutTrailing.replace(/\/api\/v4$/i, "");
+}
+
+function buildMattermostApiUrl(baseUrl: string, path: string): string {
+ const normalized = normalizeMattermostBaseUrl(baseUrl);
+ if (!normalized) throw new Error("Mattermost baseUrl is required");
+ const suffix = path.startsWith("/") ? path : `/${path}`;
+ return `${normalized}/api/v4${suffix}`;
+}
+
+async function readMattermostError(res: Response): Promise {
+ const contentType = res.headers.get("content-type") ?? "";
+ if (contentType.includes("application/json")) {
+ const data = (await res.json()) as { message?: string } | undefined;
+ if (data?.message) return data.message;
+ return JSON.stringify(data);
+ }
+ return await res.text();
+}
+
+export function createMattermostClient(params: {
+ baseUrl: string;
+ botToken: string;
+ fetchImpl?: typeof fetch;
+}): MattermostClient {
+ const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
+ if (!baseUrl) throw new Error("Mattermost baseUrl is required");
+ const apiBaseUrl = `${baseUrl}/api/v4`;
+ const token = params.botToken.trim();
+ const fetchImpl = params.fetchImpl ?? fetch;
+
+ const request = async (path: string, init?: RequestInit): Promise => {
+ const url = buildMattermostApiUrl(baseUrl, path);
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${token}`);
+ if (typeof init?.body === "string" && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+ const res = await fetchImpl(url, { ...init, headers });
+ if (!res.ok) {
+ const detail = await readMattermostError(res);
+ throw new Error(
+ `Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
+ );
+ }
+ return (await res.json()) as T;
+ };
+
+ return { baseUrl, apiBaseUrl, token, request };
+}
+
+export async function fetchMattermostMe(client: MattermostClient): Promise {
+ return await client.request("/users/me");
+}
+
+export async function fetchMattermostUser(
+ client: MattermostClient,
+ userId: string,
+): Promise {
+ return await client.request(`/users/${userId}`);
+}
+
+export async function fetchMattermostUserByUsername(
+ client: MattermostClient,
+ username: string,
+): Promise {
+ return await client.request(`/users/username/${encodeURIComponent(username)}`);
+}
+
+export async function fetchMattermostChannel(
+ client: MattermostClient,
+ channelId: string,
+): Promise {
+ return await client.request(`/channels/${channelId}`);
+}
+
+export async function sendMattermostTyping(
+ client: MattermostClient,
+ params: { channelId: string; parentId?: string },
+): Promise {
+ const payload: Record = {
+ channel_id: params.channelId,
+ };
+ const parentId = params.parentId?.trim();
+ if (parentId) payload.parent_id = parentId;
+ await client.request>("/users/me/typing", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function createMattermostDirectChannel(
+ client: MattermostClient,
+ userIds: string[],
+): Promise {
+ return await client.request("/channels/direct", {
+ method: "POST",
+ body: JSON.stringify(userIds),
+ });
+}
+
+export async function createMattermostPost(
+ client: MattermostClient,
+ params: {
+ channelId: string;
+ message: string;
+ rootId?: string;
+ fileIds?: string[];
+ },
+): Promise {
+ const payload: Record = {
+ channel_id: params.channelId,
+ message: params.message,
+ };
+ if (params.rootId) payload.root_id = params.rootId;
+ if (params.fileIds?.length) {
+ (payload as Record).file_ids = params.fileIds;
+ }
+ return await client.request("/posts", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function uploadMattermostFile(
+ client: MattermostClient,
+ params: {
+ channelId: string;
+ buffer: Buffer;
+ fileName: string;
+ contentType?: string;
+ },
+): Promise {
+ const form = new FormData();
+ const fileName = params.fileName?.trim() || "upload";
+ const bytes = Uint8Array.from(params.buffer);
+ const blob = params.contentType
+ ? new Blob([bytes], { type: params.contentType })
+ : new Blob([bytes]);
+ form.append("files", blob, fileName);
+ form.append("channel_id", params.channelId);
+
+ const res = await fetch(`${client.apiBaseUrl}/files`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${client.token}`,
+ },
+ body: form,
+ });
+
+ if (!res.ok) {
+ const detail = await readMattermostError(res);
+ throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
+ }
+
+ const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
+ const info = data.file_infos?.[0];
+ if (!info?.id) {
+ throw new Error("Mattermost file upload failed");
+ }
+ return info;
+}
diff --git a/src/mattermost/index.ts b/src/mattermost/index.ts
new file mode 100644
index 000000000..9d09fc402
--- /dev/null
+++ b/src/mattermost/index.ts
@@ -0,0 +1,9 @@
+export {
+ listEnabledMattermostAccounts,
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+} from "./accounts.js";
+export { monitorMattermostProvider } from "./monitor.js";
+export { probeMattermost } from "./probe.js";
+export { sendMessageMattermost } from "./send.js";
diff --git a/src/mattermost/monitor.ts b/src/mattermost/monitor.ts
new file mode 100644
index 000000000..fb8bd00db
--- /dev/null
+++ b/src/mattermost/monitor.ts
@@ -0,0 +1,774 @@
+import WebSocket from "ws";
+
+import {
+ resolveEffectiveMessagesConfig,
+ resolveHumanDelayConfig,
+ resolveIdentityName,
+} from "../agents/identity.js";
+import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
+import { hasControlCommand } from "../auto-reply/command-detection.js";
+import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js";
+import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js";
+import {
+ createInboundDebouncer,
+ resolveInboundDebounceMs,
+} from "../auto-reply/inbound-debounce.js";
+import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
+import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
+import {
+ buildPendingHistoryContextFromMap,
+ clearHistoryEntries,
+ DEFAULT_GROUP_HISTORY_LIMIT,
+ recordPendingHistoryEntry,
+ type HistoryEntry,
+} from "../auto-reply/reply/history.js";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
+import {
+ extractShortModelName,
+ type ResponsePrefixContext,
+} from "../auto-reply/reply/response-prefix-template.js";
+import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
+import type { ReplyPayload } from "../auto-reply/types.js";
+import type { ClawdbotConfig } from "../config/config.js";
+import { loadConfig } from "../config/config.js";
+import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
+import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
+import { createDedupeCache } from "../infra/dedupe.js";
+import { rawDataToString } from "../infra/ws.js";
+import { recordChannelActivity } from "../infra/channel-activity.js";
+import { enqueueSystemEvent } from "../infra/system-events.js";
+import { getChildLogger } from "../logging.js";
+import { mediaKindFromMime, type MediaKind } from "../media/constants.js";
+import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js";
+import { saveMediaBuffer } from "../media/store.js";
+import { resolveAgentRoute } from "../routing/resolve-route.js";
+import { resolveThreadSessionKeys } from "../routing/session-key.js";
+import type { RuntimeEnv } from "../runtime.js";
+import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
+import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
+import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
+import { resolveMattermostAccount } from "./accounts.js";
+import {
+ createMattermostClient,
+ fetchMattermostChannel,
+ fetchMattermostMe,
+ fetchMattermostUser,
+ normalizeMattermostBaseUrl,
+ sendMattermostTyping,
+ type MattermostChannel,
+ type MattermostPost,
+ type MattermostUser,
+} from "./client.js";
+import { sendMessageMattermost } from "./send.js";
+
+export type MonitorMattermostOpts = {
+ botToken?: string;
+ baseUrl?: string;
+ accountId?: string;
+ config?: ClawdbotConfig;
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+ statusSink?: (patch: Partial) => void;
+};
+
+type MattermostEventPayload = {
+ event?: string;
+ data?: {
+ post?: string;
+ channel_id?: string;
+ channel_name?: string;
+ channel_display_name?: string;
+ channel_type?: string;
+ sender_name?: string;
+ team_id?: string;
+ };
+ broadcast?: {
+ channel_id?: string;
+ team_id?: string;
+ user_id?: string;
+ };
+};
+
+const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
+const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
+const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
+const USER_CACHE_TTL_MS = 10 * 60_000;
+const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
+
+const recentInboundMessages = createDedupeCache({
+ ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
+ maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
+});
+
+function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
+ return (
+ opts.runtime ?? {
+ log: console.log,
+ error: console.error,
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ }
+ );
+}
+
+function normalizeMention(text: string, mention: string | undefined): string {
+ if (!mention) return text.trim();
+ const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(`@${escaped}\\b`, "gi");
+ return text.replace(re, " ").replace(/\s+/g, " ").trim();
+}
+
+function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
+ const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
+ return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
+}
+
+function stripOncharPrefix(
+ text: string,
+ prefixes: string[],
+): { triggered: boolean; stripped: string } {
+ const trimmed = text.trimStart();
+ for (const prefix of prefixes) {
+ if (!prefix) continue;
+ if (trimmed.startsWith(prefix)) {
+ return {
+ triggered: true,
+ stripped: trimmed.slice(prefix.length).trimStart(),
+ };
+ }
+ }
+ return { triggered: false, stripped: text };
+}
+
+function isSystemPost(post: MattermostPost): boolean {
+ const type = post.type?.trim();
+ return Boolean(type);
+}
+
+function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
+ if (!channelType) return "channel";
+ const normalized = channelType.trim().toUpperCase();
+ if (normalized === "D") return "dm";
+ if (normalized === "G") return "group";
+ return "channel";
+}
+
+function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
+ if (kind === "dm") return "direct";
+ if (kind === "group") return "group";
+ return "channel";
+}
+
+type MattermostMediaInfo = {
+ path: string;
+ contentType?: string;
+ kind: MediaKind;
+};
+
+function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
+ if (mediaList.length === 0) return "";
+ if (mediaList.length === 1) {
+ const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
+ return ``;
+ }
+ const allImages = mediaList.every((media) => media.kind === "image");
+ const label = allImages ? "image" : "file";
+ const suffix = mediaList.length === 1 ? label : `${label}s`;
+ const tag = allImages ? "" : "";
+ return `${tag} (${mediaList.length} ${suffix})`;
+}
+
+function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
+ MediaPath?: string;
+ MediaType?: string;
+ MediaUrl?: string;
+ MediaPaths?: string[];
+ MediaUrls?: string[];
+ MediaTypes?: string[];
+} {
+ const first = mediaList[0];
+ const mediaPaths = mediaList.map((media) => media.path);
+ const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
+ return {
+ MediaPath: first?.path,
+ MediaType: first?.contentType,
+ MediaUrl: first?.path,
+ MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
+ MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
+ MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
+ };
+}
+
+function buildMattermostWsUrl(baseUrl: string): string {
+ const normalized = normalizeMattermostBaseUrl(baseUrl);
+ if (!normalized) throw new Error("Mattermost baseUrl is required");
+ const wsBase = normalized.replace(/^http/i, "ws");
+ return `${wsBase}/api/v4/websocket`;
+}
+
+export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise {
+ const runtime = resolveRuntime(opts);
+ const cfg = opts.config ?? loadConfig();
+ const account = resolveMattermostAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const botToken = opts.botToken?.trim() || account.botToken?.trim();
+ if (!botToken) {
+ throw new Error(
+ `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
+ );
+ }
+ const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
+ if (!baseUrl) {
+ throw new Error(
+ `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
+ );
+ }
+
+ const client = createMattermostClient({ baseUrl, botToken });
+ const botUser = await fetchMattermostMe(client);
+ const botUserId = botUser.id;
+ const botUsername = botUser.username?.trim() || undefined;
+ runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
+
+ const channelCache = new Map();
+ const userCache = new Map();
+ const logger = getChildLogger({ module: "mattermost" });
+ const mediaMaxBytes =
+ resolveChannelMediaMaxBytes({
+ cfg,
+ resolveChannelLimitMb: () => undefined,
+ accountId: account.accountId,
+ }) ?? 8 * 1024 * 1024;
+ const historyLimit = Math.max(
+ 0,
+ cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
+ );
+ const channelHistories = new Map();
+
+ const fetchWithAuth: FetchLike = (input, init) => {
+ const headers = new Headers(init?.headers);
+ headers.set("Authorization", `Bearer ${client.token}`);
+ return fetch(input, { ...init, headers });
+ };
+
+ const resolveMattermostMedia = async (
+ fileIds?: string[] | null,
+ ): Promise => {
+ const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
+ if (ids.length === 0) return [];
+ const out: MattermostMediaInfo[] = [];
+ for (const fileId of ids) {
+ try {
+ const fetched = await fetchRemoteMedia({
+ url: `${client.apiBaseUrl}/files/${fileId}`,
+ fetchImpl: fetchWithAuth,
+ filePathHint: fileId,
+ maxBytes: mediaMaxBytes,
+ });
+ const saved = await saveMediaBuffer(
+ fetched.buffer,
+ fetched.contentType ?? undefined,
+ "inbound",
+ mediaMaxBytes,
+ );
+ const contentType = saved.contentType ?? fetched.contentType ?? undefined;
+ out.push({
+ path: saved.path,
+ contentType,
+ kind: mediaKindFromMime(contentType),
+ });
+ } catch (err) {
+ logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
+ }
+ }
+ return out;
+ };
+
+ const sendTypingIndicator = async (channelId: string, parentId?: string) => {
+ try {
+ await sendMattermostTyping(client, { channelId, parentId });
+ } catch (err) {
+ logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
+ }
+ };
+
+ const resolveChannelInfo = async (channelId: string): Promise => {
+ const cached = channelCache.get(channelId);
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
+ try {
+ const info = await fetchMattermostChannel(client, channelId);
+ channelCache.set(channelId, {
+ value: info,
+ expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
+ });
+ return info;
+ } catch (err) {
+ logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
+ channelCache.set(channelId, {
+ value: null,
+ expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
+ });
+ return null;
+ }
+ };
+
+ const resolveUserInfo = async (userId: string): Promise => {
+ const cached = userCache.get(userId);
+ if (cached && cached.expiresAt > Date.now()) return cached.value;
+ try {
+ const info = await fetchMattermostUser(client, userId);
+ userCache.set(userId, {
+ value: info,
+ expiresAt: Date.now() + USER_CACHE_TTL_MS,
+ });
+ return info;
+ } catch (err) {
+ logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
+ userCache.set(userId, {
+ value: null,
+ expiresAt: Date.now() + USER_CACHE_TTL_MS,
+ });
+ return null;
+ }
+ };
+
+ const handlePost = async (
+ post: MattermostPost,
+ payload: MattermostEventPayload,
+ messageIds?: string[],
+ ) => {
+ const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
+ if (!channelId) return;
+
+ const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
+ if (allMessageIds.length === 0) return;
+ const dedupeEntries = allMessageIds.map((id) =>
+ recentInboundMessages.check(`${account.accountId}:${id}`),
+ );
+ if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
+
+ const senderId = post.user_id ?? payload.broadcast?.user_id;
+ if (!senderId) return;
+ if (senderId === botUserId) return;
+ if (isSystemPost(post)) return;
+
+ const channelInfo = await resolveChannelInfo(channelId);
+ const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
+ const kind = channelKind(channelType);
+ const chatType = channelChatType(kind);
+
+ const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
+ const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
+ const channelDisplay =
+ payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
+ const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
+
+ const route = resolveAgentRoute({
+ cfg,
+ channel: "mattermost",
+ accountId: account.accountId,
+ teamId,
+ peer: {
+ kind,
+ id: kind === "dm" ? senderId : channelId,
+ },
+ });
+
+ const baseSessionKey = route.sessionKey;
+ const threadRootId = post.root_id?.trim() || undefined;
+ const threadKeys = resolveThreadSessionKeys({
+ baseSessionKey,
+ threadId: threadRootId,
+ parentSessionKey: threadRootId ? baseSessionKey : undefined,
+ });
+ const sessionKey = threadKeys.sessionKey;
+ const historyKey = kind === "dm" ? null : sessionKey;
+
+ const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
+ const rawText = post.message?.trim() || "";
+ const wasMentioned =
+ kind !== "dm" &&
+ ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
+ matchesMentionPatterns(rawText, mentionRegexes));
+ const pendingBody =
+ rawText ||
+ (post.file_ids?.length
+ ? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
+ : "");
+ const pendingSender = payload.data?.sender_name?.trim() || senderId;
+ const recordPendingHistory = () => {
+ if (!historyKey || historyLimit <= 0) return;
+ const trimmed = pendingBody.trim();
+ if (!trimmed) return;
+ recordPendingHistoryEntry({
+ historyMap: channelHistories,
+ historyKey,
+ limit: historyLimit,
+ entry: {
+ sender: pendingSender,
+ body: trimmed,
+ timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ messageId: post.id ?? undefined,
+ },
+ });
+ };
+
+ const allowTextCommands = shouldHandleTextCommands({
+ cfg,
+ surface: "mattermost",
+ });
+ const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
+ const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
+ const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
+ const oncharResult = oncharEnabled
+ ? stripOncharPrefix(rawText, oncharPrefixes)
+ : { triggered: false, stripped: rawText };
+ const oncharTriggered = oncharResult.triggered;
+
+ const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
+ const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
+ const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
+ const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
+
+ if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
+ recordPendingHistory();
+ return;
+ }
+
+ if (kind === "channel" && shouldRequireMention && canDetectMention) {
+ if (!effectiveWasMentioned) {
+ recordPendingHistory();
+ return;
+ }
+ }
+
+ const senderName =
+ payload.data?.sender_name?.trim() ||
+ (await resolveUserInfo(senderId))?.username?.trim() ||
+ senderId;
+ const mediaList = await resolveMattermostMedia(post.file_ids);
+ const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
+ const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
+ const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
+ const bodyText = normalizeMention(baseText, botUsername);
+ if (!bodyText) return;
+
+ recordChannelActivity({
+ channel: "mattermost",
+ accountId: account.accountId,
+ direction: "inbound",
+ });
+
+ const fromLabel = formatInboundFromLabel({
+ isGroup: kind !== "dm",
+ groupLabel: channelDisplay || roomLabel,
+ groupId: channelId,
+ groupFallback: roomLabel || "Channel",
+ directLabel: senderName,
+ directId: senderId,
+ });
+
+ const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
+ const inboundLabel =
+ kind === "dm"
+ ? `Mattermost DM from ${senderName}`
+ : `Mattermost message in ${roomLabel} from ${senderName}`;
+ enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
+ sessionKey,
+ contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
+ });
+
+ const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
+ const body = formatInboundEnvelope({
+ channel: "Mattermost",
+ from: fromLabel,
+ timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ body: textWithId,
+ chatType,
+ sender: { name: senderName, id: senderId },
+ });
+ let combinedBody = body;
+ if (historyKey && historyLimit > 0) {
+ combinedBody = buildPendingHistoryContextFromMap({
+ historyMap: channelHistories,
+ historyKey,
+ limit: historyLimit,
+ currentMessage: combinedBody,
+ formatEntry: (entry) =>
+ formatInboundEnvelope({
+ channel: "Mattermost",
+ from: fromLabel,
+ timestamp: entry.timestamp,
+ body: `${entry.body}${
+ entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
+ }`,
+ chatType,
+ senderLabel: entry.sender,
+ }),
+ });
+ }
+
+ const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
+ const mediaPayload = buildMattermostMediaPayload(mediaList);
+ const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
+ useAccessGroups: cfg.commands?.useAccessGroups ?? false,
+ authorizers: [],
+ });
+ const ctxPayload = finalizeInboundContext({
+ Body: combinedBody,
+ RawBody: bodyText,
+ CommandBody: bodyText,
+ From:
+ kind === "dm"
+ ? `mattermost:${senderId}`
+ : kind === "group"
+ ? `mattermost:group:${channelId}`
+ : `mattermost:channel:${channelId}`,
+ To: to,
+ SessionKey: sessionKey,
+ ParentSessionKey: threadKeys.parentSessionKey,
+ AccountId: route.accountId,
+ ChatType: chatType,
+ ConversationLabel: fromLabel,
+ GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
+ GroupChannel: channelName ? `#${channelName}` : undefined,
+ GroupSpace: teamId,
+ SenderName: senderName,
+ SenderId: senderId,
+ Provider: "mattermost" as const,
+ Surface: "mattermost" as const,
+ MessageSid: post.id ?? undefined,
+ MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
+ MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
+ MessageSidLast:
+ allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
+ ReplyToId: threadRootId,
+ MessageThreadId: threadRootId,
+ Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
+ CommandAuthorized: commandAuthorized,
+ OriginatingChannel: "mattermost" as const,
+ OriginatingTo: to,
+ ...mediaPayload,
+ });
+
+ if (kind === "dm") {
+ const sessionCfg = cfg.session;
+ const storePath = resolveStorePath(sessionCfg?.store, {
+ agentId: route.agentId,
+ });
+ await updateLastRoute({
+ storePath,
+ sessionKey: route.mainSessionKey,
+ deliveryContext: {
+ channel: "mattermost",
+ to,
+ accountId: route.accountId,
+ },
+ });
+ }
+
+ if (shouldLogVerbose()) {
+ const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
+ logVerbose(
+ `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
+ );
+ }
+
+ const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
+ fallbackLimit: account.textChunkLimit ?? 4000,
+ });
+
+ let prefixContext: ResponsePrefixContext = {
+ identityName: resolveIdentityName(cfg, route.agentId),
+ };
+
+ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
+ deliver: async (payload: ReplyPayload) => {
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+ if (mediaUrls.length === 0) {
+ const chunks = chunkMarkdownText(text, textLimit);
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
+ if (!chunk) continue;
+ await sendMessageMattermost(to, chunk, {
+ accountId: account.accountId,
+ replyToId: threadRootId,
+ });
+ }
+ } else {
+ let first = true;
+ for (const mediaUrl of mediaUrls) {
+ const caption = first ? text : "";
+ first = false;
+ await sendMessageMattermost(to, caption, {
+ accountId: account.accountId,
+ mediaUrl,
+ replyToId: threadRootId,
+ });
+ }
+ }
+ runtime.log?.(`delivered reply to ${to}`);
+ },
+ onError: (err, info) => {
+ runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`));
+ },
+ onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ });
+
+ await dispatchReplyFromConfig({
+ ctx: ctxPayload,
+ cfg,
+ dispatcher,
+ replyOptions: {
+ ...replyOptions,
+ disableBlockStreaming:
+ typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
+ onModelSelected: (ctx) => {
+ prefixContext.provider = ctx.provider;
+ prefixContext.model = extractShortModelName(ctx.model);
+ prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
+ prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ },
+ },
+ });
+ markDispatchIdle();
+ if (historyKey && historyLimit > 0) {
+ clearHistoryEntries({ historyMap: channelHistories, historyKey });
+ }
+ };
+
+ const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
+ const debouncer = createInboundDebouncer<{
+ post: MattermostPost;
+ payload: MattermostEventPayload;
+ }>({
+ debounceMs: inboundDebounceMs,
+ buildKey: (entry) => {
+ const channelId =
+ entry.post.channel_id ??
+ entry.payload.data?.channel_id ??
+ entry.payload.broadcast?.channel_id;
+ if (!channelId) return null;
+ const threadId = entry.post.root_id?.trim();
+ const threadKey = threadId ? `thread:${threadId}` : "channel";
+ return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
+ },
+ shouldDebounce: (entry) => {
+ if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
+ const text = entry.post.message?.trim() ?? "";
+ if (!text) return false;
+ return !hasControlCommand(text, cfg);
+ },
+ onFlush: async (entries) => {
+ const last = entries.at(-1);
+ if (!last) return;
+ if (entries.length === 1) {
+ await handlePost(last.post, last.payload);
+ return;
+ }
+ const combinedText = entries
+ .map((entry) => entry.post.message?.trim() ?? "")
+ .filter(Boolean)
+ .join("\n");
+ const mergedPost: MattermostPost = {
+ ...last.post,
+ message: combinedText,
+ file_ids: [],
+ };
+ const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
+ await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
+ },
+ onError: (err) => {
+ runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
+ },
+ });
+
+ const wsUrl = buildMattermostWsUrl(baseUrl);
+ let seq = 1;
+
+ const connectOnce = async (): Promise => {
+ const ws = new WebSocket(wsUrl);
+ const onAbort = () => ws.close();
+ opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
+
+ return await new Promise((resolve) => {
+ ws.on("open", () => {
+ opts.statusSink?.({
+ connected: true,
+ lastConnectedAt: Date.now(),
+ lastError: null,
+ });
+ ws.send(
+ JSON.stringify({
+ seq: seq++,
+ action: "authentication_challenge",
+ data: { token: botToken },
+ }),
+ );
+ });
+
+ ws.on("message", async (data) => {
+ const raw = rawDataToString(data);
+ let payload: MattermostEventPayload;
+ try {
+ payload = JSON.parse(raw) as MattermostEventPayload;
+ } catch {
+ return;
+ }
+ if (payload.event !== "posted") return;
+ const postData = payload.data?.post;
+ if (!postData) return;
+ let post: MattermostPost | null = null;
+ if (typeof postData === "string") {
+ try {
+ post = JSON.parse(postData) as MattermostPost;
+ } catch {
+ return;
+ }
+ } else if (typeof postData === "object") {
+ post = postData as MattermostPost;
+ }
+ if (!post) return;
+ try {
+ await debouncer.enqueue({ post, payload });
+ } catch (err) {
+ runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
+ }
+ });
+
+ ws.on("close", (code, reason) => {
+ const message = reason.length > 0 ? reason.toString("utf8") : "";
+ opts.statusSink?.({
+ connected: false,
+ lastDisconnect: {
+ at: Date.now(),
+ status: code,
+ error: message || undefined,
+ },
+ });
+ opts.abortSignal?.removeEventListener("abort", onAbort);
+ resolve();
+ });
+
+ ws.on("error", (err) => {
+ runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
+ opts.statusSink?.({
+ lastError: String(err),
+ });
+ });
+ });
+ };
+
+ while (!opts.abortSignal?.aborted) {
+ await connectOnce();
+ if (opts.abortSignal?.aborted) return;
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+}
diff --git a/src/mattermost/probe.ts b/src/mattermost/probe.ts
new file mode 100644
index 000000000..c0fa8ae63
--- /dev/null
+++ b/src/mattermost/probe.ts
@@ -0,0 +1,70 @@
+import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
+
+export type MattermostProbe = {
+ ok: boolean;
+ status?: number | null;
+ error?: string | null;
+ elapsedMs?: number | null;
+ bot?: MattermostUser;
+};
+
+async function readMattermostError(res: Response): Promise {
+ const contentType = res.headers.get("content-type") ?? "";
+ if (contentType.includes("application/json")) {
+ const data = (await res.json()) as { message?: string } | undefined;
+ if (data?.message) return data.message;
+ return JSON.stringify(data);
+ }
+ return await res.text();
+}
+
+export async function probeMattermost(
+ baseUrl: string,
+ botToken: string,
+ timeoutMs = 2500,
+): Promise {
+ const normalized = normalizeMattermostBaseUrl(baseUrl);
+ if (!normalized) {
+ return { ok: false, error: "baseUrl missing" };
+ }
+ const url = `${normalized}/api/v4/users/me`;
+ const start = Date.now();
+ const controller = timeoutMs > 0 ? new AbortController() : undefined;
+ let timer: NodeJS.Timeout | null = null;
+ if (controller) {
+ timer = setTimeout(() => controller.abort(), timeoutMs);
+ }
+ try {
+ const res = await fetch(url, {
+ headers: { Authorization: `Bearer ${botToken}` },
+ signal: controller?.signal,
+ });
+ const elapsedMs = Date.now() - start;
+ if (!res.ok) {
+ const detail = await readMattermostError(res);
+ return {
+ ok: false,
+ status: res.status,
+ error: detail || res.statusText,
+ elapsedMs,
+ };
+ }
+ const bot = (await res.json()) as MattermostUser;
+ return {
+ ok: true,
+ status: res.status,
+ elapsedMs,
+ bot,
+ };
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ ok: false,
+ status: null,
+ error: message,
+ elapsedMs: Date.now() - start,
+ };
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
diff --git a/src/mattermost/send.ts b/src/mattermost/send.ts
new file mode 100644
index 000000000..40f038cc0
--- /dev/null
+++ b/src/mattermost/send.ts
@@ -0,0 +1,207 @@
+import { loadConfig } from "../config/config.js";
+import { logVerbose, shouldLogVerbose } from "../globals.js";
+import { recordChannelActivity } from "../infra/channel-activity.js";
+import { loadWebMedia } from "../web/media.js";
+import { resolveMattermostAccount } from "./accounts.js";
+import {
+ createMattermostClient,
+ createMattermostDirectChannel,
+ createMattermostPost,
+ fetchMattermostMe,
+ fetchMattermostUserByUsername,
+ normalizeMattermostBaseUrl,
+ uploadMattermostFile,
+ type MattermostUser,
+} from "./client.js";
+
+export type MattermostSendOpts = {
+ botToken?: string;
+ baseUrl?: string;
+ accountId?: string;
+ mediaUrl?: string;
+ replyToId?: string;
+};
+
+export type MattermostSendResult = {
+ messageId: string;
+ channelId: string;
+};
+
+type MattermostTarget =
+ | { kind: "channel"; id: string }
+ | { kind: "user"; id?: string; username?: string };
+
+const botUserCache = new Map();
+const userByNameCache = new Map();
+
+function cacheKey(baseUrl: string, token: string): string {
+ return `${baseUrl}::${token}`;
+}
+
+function normalizeMessage(text: string, mediaUrl?: string): string {
+ const trimmed = text.trim();
+ const media = mediaUrl?.trim();
+ return [trimmed, media].filter(Boolean).join("\n");
+}
+
+function isHttpUrl(value: string): boolean {
+ return /^https?:\/\//i.test(value);
+}
+
+function parseMattermostTarget(raw: string): MattermostTarget {
+ const trimmed = raw.trim();
+ if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
+ const lower = trimmed.toLowerCase();
+ if (lower.startsWith("channel:")) {
+ const id = trimmed.slice("channel:".length).trim();
+ if (!id) throw new Error("Channel id is required for Mattermost sends");
+ return { kind: "channel", id };
+ }
+ if (lower.startsWith("user:")) {
+ const id = trimmed.slice("user:".length).trim();
+ if (!id) throw new Error("User id is required for Mattermost sends");
+ return { kind: "user", id };
+ }
+ if (lower.startsWith("mattermost:")) {
+ const id = trimmed.slice("mattermost:".length).trim();
+ if (!id) throw new Error("User id is required for Mattermost sends");
+ return { kind: "user", id };
+ }
+ if (trimmed.startsWith("@")) {
+ const username = trimmed.slice(1).trim();
+ if (!username) {
+ throw new Error("Username is required for Mattermost sends");
+ }
+ return { kind: "user", username };
+ }
+ return { kind: "channel", id: trimmed };
+}
+
+async function resolveBotUser(baseUrl: string, token: string): Promise {
+ const key = cacheKey(baseUrl, token);
+ const cached = botUserCache.get(key);
+ if (cached) return cached;
+ const client = createMattermostClient({ baseUrl, botToken: token });
+ const user = await fetchMattermostMe(client);
+ botUserCache.set(key, user);
+ return user;
+}
+
+async function resolveUserIdByUsername(params: {
+ baseUrl: string;
+ token: string;
+ username: string;
+}): Promise {
+ const { baseUrl, token, username } = params;
+ const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
+ const cached = userByNameCache.get(key);
+ if (cached?.id) return cached.id;
+ const client = createMattermostClient({ baseUrl, botToken: token });
+ const user = await fetchMattermostUserByUsername(client, username);
+ userByNameCache.set(key, user);
+ return user.id;
+}
+
+async function resolveTargetChannelId(params: {
+ target: MattermostTarget;
+ baseUrl: string;
+ token: string;
+}): Promise {
+ if (params.target.kind === "channel") return params.target.id;
+ const userId = params.target.id
+ ? params.target.id
+ : await resolveUserIdByUsername({
+ baseUrl: params.baseUrl,
+ token: params.token,
+ username: params.target.username ?? "",
+ });
+ const botUser = await resolveBotUser(params.baseUrl, params.token);
+ const client = createMattermostClient({
+ baseUrl: params.baseUrl,
+ botToken: params.token,
+ });
+ const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
+ return channel.id;
+}
+
+export async function sendMessageMattermost(
+ to: string,
+ text: string,
+ opts: MattermostSendOpts = {},
+): Promise {
+ const cfg = loadConfig();
+ const account = resolveMattermostAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const token = opts.botToken?.trim() || account.botToken?.trim();
+ if (!token) {
+ throw new Error(
+ `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
+ );
+ }
+ const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
+ if (!baseUrl) {
+ throw new Error(
+ `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
+ );
+ }
+
+ const target = parseMattermostTarget(to);
+ const channelId = await resolveTargetChannelId({
+ target,
+ baseUrl,
+ token,
+ });
+
+ const client = createMattermostClient({ baseUrl, botToken: token });
+ let message = text?.trim() ?? "";
+ let fileIds: string[] | undefined;
+ let uploadError: Error | undefined;
+ const mediaUrl = opts.mediaUrl?.trim();
+ if (mediaUrl) {
+ try {
+ const media = await loadWebMedia(mediaUrl);
+ const fileInfo = await uploadMattermostFile(client, {
+ channelId,
+ buffer: media.buffer,
+ fileName: media.fileName ?? "upload",
+ contentType: media.contentType ?? undefined,
+ });
+ fileIds = [fileInfo.id];
+ } catch (err) {
+ uploadError = err instanceof Error ? err : new Error(String(err));
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
+ );
+ }
+ message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
+ }
+ }
+
+ if (!message && (!fileIds || fileIds.length === 0)) {
+ if (uploadError) {
+ throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
+ }
+ throw new Error("Mattermost message is empty");
+ }
+
+ const post = await createMattermostPost(client, {
+ channelId,
+ message,
+ rootId: opts.replyToId,
+ fileIds,
+ });
+
+ recordChannelActivity({
+ channel: "mattermost",
+ accountId: account.accountId,
+ direction: "outbound",
+ });
+
+ return {
+ messageId: post.id ?? "unknown",
+ channelId,
+ };
+}
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 8bef1da37..62979bdd1 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -81,6 +81,7 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
+ MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -120,6 +121,7 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
+ resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -236,6 +238,21 @@ export {
normalizeSlackMessagingTarget,
} from "../channels/plugins/normalize/slack.js";
+// Channel: Mattermost
+export {
+ listEnabledMattermostAccounts,
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ type ResolvedMattermostAccount,
+} from "../mattermost/accounts.js";
+export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
+export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
+export {
+ looksLikeMattermostTargetId,
+ normalizeMattermostMessagingTarget,
+} from "../channels/plugins/normalize/mattermost.js";
+
// Channel: Telegram
export {
listTelegramAccountIds,
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index 4765c71c7..e564ad2f8 100644
--- a/src/plugins/runtime/index.ts
+++ b/src/plugins/runtime/index.ts
@@ -57,6 +57,9 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
+import { monitorMattermostProvider } from "../../mattermost/monitor.js";
+import { probeMattermost } from "../../mattermost/probe.js";
+import { sendMessageMattermost } from "../../mattermost/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
@@ -230,6 +233,11 @@ export function createPluginRuntime(): PluginRuntime {
monitorSlackProvider,
handleSlackAction,
},
+ mattermost: {
+ probeMattermost,
+ sendMessageMattermost,
+ monitorMattermostProvider,
+ },
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 089e20c37..31350693c 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -98,6 +98,10 @@ type ResolveSlackUserAllowlist =
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
+type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost;
+type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost;
+type MonitorMattermostProvider =
+ typeof import("../../mattermost/monitor.js").monitorMattermostProvider;
type AuditTelegramGroupMembership =
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
type CollectTelegramUnmentionedGroupIds =
@@ -242,6 +246,11 @@ export type PluginRuntime = {
monitorSlackProvider: MonitorSlackProvider;
handleSlackAction: HandleSlackAction;
};
+ mattermost: {
+ probeMattermost: ProbeMattermost;
+ sendMessageMattermost: SendMessageMattermost;
+ monitorMattermostProvider: MonitorMattermostProvider;
+ };
telegram: {
auditGroupMembership: AuditTelegramGroupMembership;
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts
index ecd1f713b..c09436ac8 100644
--- a/src/utils/message-channel.ts
+++ b/src/utils/message-channel.ts
@@ -22,6 +22,7 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set([
"telegram",
"signal",
"discord",
+ "mattermost",
"tui",
INTERNAL_MESSAGE_CHANNEL,
]);
diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts
index 6cdbfb029..aaf89b9e9 100644
--- a/ui/src/ui/types.ts
+++ b/ui/src/ui/types.ts
@@ -164,6 +164,39 @@ export type SlackStatus = {
lastProbeAt?: number | null;
};
+export type MattermostBot = {
+ id?: string | null;
+ username?: string | null;
+};
+
+export type MattermostProbe = {
+ ok: boolean;
+ status?: number | null;
+ error?: string | null;
+ elapsedMs?: number | null;
+ bot?: MattermostBot | null;
+};
+
+export type MattermostStatus = {
+ configured: boolean;
+ botTokenSource?: string | null;
+ running: boolean;
+ connected?: boolean | null;
+ lastConnectedAt?: number | null;
+ lastDisconnect?: {
+ at: number;
+ status?: number | null;
+ error?: string | null;
+ loggedOut?: boolean | null;
+ } | null;
+ lastStartAt?: number | null;
+ lastStopAt?: number | null;
+ lastError?: string | null;
+ baseUrl?: string | null;
+ probe?: MattermostProbe | null;
+ lastProbeAt?: number | null;
+};
+
export type SignalProbe = {
ok: boolean;
status?: number | null;
@@ -363,6 +396,7 @@ export type CronPayload =
| "telegram"
| "discord"
| "slack"
+ | "mattermost"
| "signal"
| "imessage"
| "msteams";
diff --git a/ui/src/ui/views/channels.mattermost.ts b/ui/src/ui/views/channels.mattermost.ts
new file mode 100644
index 000000000..c2513ed44
--- /dev/null
+++ b/ui/src/ui/views/channels.mattermost.ts
@@ -0,0 +1,70 @@
+import { html, nothing } from "lit";
+
+import { formatAgo } from "../format";
+import type { MattermostStatus } from "../types";
+import type { ChannelsProps } from "./channels.types";
+import { renderChannelConfigSection } from "./channels.config";
+
+export function renderMattermostCard(params: {
+ props: ChannelsProps;
+ mattermost?: MattermostStatus | null;
+ accountCountLabel: unknown;
+}) {
+ const { props, mattermost, accountCountLabel } = params;
+
+ return html`
+
+ Mattermost
+ Bot token + WebSocket status and configuration.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${mattermost?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${mattermost?.running ? "Yes" : "No"}
+
+
+ Connected
+ ${mattermost?.connected ? "Yes" : "No"}
+
+
+ Base URL
+ ${mattermost?.baseUrl || "n/a"}
+
+
+ Last start
+ ${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"}
+
+
+
+ ${mattermost?.lastError
+ ? html`
+ ${mattermost.lastError}
+ `
+ : nothing}
+
+ ${mattermost?.probe
+ ? html`
+ Probe ${mattermost.probe.ok ? "ok" : "failed"} -
+ ${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""}
+ `
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "mattermost", props })}
+
+
+
+
+
+ `;
+}
diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts
index 232cf2c85..96a6b8556 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -7,6 +7,7 @@ import type {
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
+ MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -23,6 +24,7 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
+import { renderMattermostCard } from "./channels.mattermost";
import { renderNostrCard } from "./channels.nostr";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
@@ -39,6 +41,7 @@ export function renderChannels(props: ChannelsProps) {
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
+ const mattermost = (channels?.mattermost ?? null) as MattermostStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
@@ -62,6 +65,7 @@ export function renderChannels(props: ChannelsProps) {
telegram,
discord,
slack,
+ mattermost,
signal,
imessage,
nostr,
@@ -97,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
- return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
+ return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
}
function renderChannel(
@@ -135,6 +139,12 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
+ case "mattermost":
+ return renderMattermostCard({
+ props,
+ mattermost: data.mattermost,
+ accountCountLabel,
+ });
case "signal":
return renderSignalCard({
props,
diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts
index 43576d54a..d3a98d44e 100644
--- a/ui/src/ui/views/channels.types.ts
+++ b/ui/src/ui/views/channels.types.ts
@@ -4,6 +4,7 @@ import type {
ConfigUiHints,
DiscordStatus,
IMessageStatus,
+ MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -53,6 +54,7 @@ export type ChannelsChannelData = {
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
+ mattermost?: MattermostStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
nostr?: NostrStatus | null;
From 3d8a759eba36e03cbf024643cc1c9e54f3ed8749 Mon Sep 17 00:00:00 2001
From: Tobias Bischoff <711564+tobiasbischoff@users.noreply.github.com>
Date: Thu, 22 Jan 2026 10:04:56 +0100
Subject: [PATCH 02/86] fix(auth): skip auth profiles in cooldown during
selection and rotation
Auth profiles in cooldown (due to rate limiting) were being attempted,
causing unnecessary retries and delays. This fix ensures:
1. Initial profile selection skips profiles in cooldown
2. Profile rotation (after failures) skips cooldown profiles
3. Clear error message when all profiles are unavailable
Tests added:
- Skips profiles in cooldown during initial selection
- Skips profiles in cooldown when rotating after failure
Fixes #1316
---
CHANGELOG.md | 1 +
...ded-pi-agent.auth-profile-rotation.test.ts | 139 ++++++++++++++++++
src/agents/pi-embedded-runner/run.ts | 18 ++-
.../bot-message-context.sender-prefix.test.ts | 92 ++++++++++++
4 files changed, 249 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b84c324b5..46ade97f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ Docs: https://docs.clawd.bot
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
+- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Media: accept MEDIA paths with spaces/tilde and prefer the message tool hint for image replies.
- Google Antigravity: drop unsigned thinking blocks for Claude models to avoid signature errors.
- Config: avoid stack traces for invalid configs and log the config path.
diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts
index b931230af..27bd96419 100644
--- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts
+++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts
@@ -248,4 +248,143 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
+
+ it("skips profiles in cooldown during initial selection", async () => {
+ vi.useFakeTimers();
+ try {
+ const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
+ const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ try {
+ const authPath = path.join(agentDir, "auth-profiles.json");
+ const payload = {
+ version: 1,
+ profiles: {
+ "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
+ "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
+ },
+ usageStats: {
+ "openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour
+ "openai:p2": { lastUsed: 2 },
+ },
+ };
+ await fs.writeFile(authPath, JSON.stringify(payload));
+
+ runEmbeddedAttemptMock.mockResolvedValueOnce(
+ makeAttempt({
+ assistantTexts: ["ok"],
+ lastAssistant: buildAssistant({
+ stopReason: "stop",
+ content: [{ type: "text", text: "ok" }],
+ }),
+ }),
+ );
+
+ await runEmbeddedPiAgent({
+ sessionId: "session:test",
+ sessionKey: "agent:test:skip-cooldown",
+ sessionFile: path.join(workspaceDir, "session.jsonl"),
+ workspaceDir,
+ agentDir,
+ config: makeConfig(),
+ prompt: "hello",
+ provider: "openai",
+ model: "mock-1",
+ authProfileId: undefined,
+ authProfileIdSource: "auto",
+ timeoutMs: 5_000,
+ runId: "run:skip-cooldown",
+ });
+
+ expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
+
+ const stored = JSON.parse(
+ await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
+ ) as { usageStats?: Record };
+ expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
+ expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
+ } finally {
+ await fs.rm(agentDir, { recursive: true, force: true });
+ await fs.rm(workspaceDir, { recursive: true, force: true });
+ }
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("skips profiles in cooldown when rotating after failure", async () => {
+ vi.useFakeTimers();
+ try {
+ const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
+ const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
+ const now = Date.now();
+ vi.setSystemTime(now);
+
+ try {
+ const authPath = path.join(agentDir, "auth-profiles.json");
+ const payload = {
+ version: 1,
+ profiles: {
+ "openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
+ "openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
+ },
+ usageStats: {
+ "openai:p1": { lastUsed: 1 },
+ "openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown
+ },
+ };
+ await fs.writeFile(authPath, JSON.stringify(payload));
+
+ runEmbeddedAttemptMock
+ .mockResolvedValueOnce(
+ makeAttempt({
+ assistantTexts: [],
+ lastAssistant: buildAssistant({
+ stopReason: "error",
+ errorMessage: "rate limit",
+ }),
+ }),
+ )
+ .mockResolvedValueOnce(
+ makeAttempt({
+ assistantTexts: ["ok"],
+ lastAssistant: buildAssistant({
+ stopReason: "stop",
+ content: [{ type: "text", text: "ok" }],
+ }),
+ }),
+ );
+
+ await runEmbeddedPiAgent({
+ sessionId: "session:test",
+ sessionKey: "agent:test:rotate-skip-cooldown",
+ sessionFile: path.join(workspaceDir, "session.jsonl"),
+ workspaceDir,
+ agentDir,
+ config: makeConfig(),
+ prompt: "hello",
+ provider: "openai",
+ model: "mock-1",
+ authProfileId: "openai:p1",
+ authProfileIdSource: "auto",
+ timeoutMs: 5_000,
+ runId: "run:rotate-skip-cooldown",
+ });
+
+ expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
+
+ const stored = JSON.parse(
+ await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
+ ) as { usageStats?: Record };
+ expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number");
+ } finally {
+ await fs.rm(agentDir, { recursive: true, force: true });
+ await fs.rm(workspaceDir, { recursive: true, force: true });
+ }
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts
index 174178b09..a2af256cc 100644
--- a/src/agents/pi-embedded-runner/run.ts
+++ b/src/agents/pi-embedded-runner/run.ts
@@ -5,6 +5,7 @@ import { resolveUserPath } from "../../utils.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import {
+ isProfileInCooldown,
markAuthProfileFailure,
markAuthProfileGood,
markAuthProfileUsed,
@@ -196,6 +197,10 @@ export async function runEmbeddedPiAgent(
let nextIndex = profileIndex + 1;
while (nextIndex < profileCandidates.length) {
const candidate = profileCandidates[nextIndex];
+ if (candidate && isProfileInCooldown(authStore, candidate)) {
+ nextIndex += 1;
+ continue;
+ }
try {
await applyApiKeyInfo(candidate);
profileIndex = nextIndex;
@@ -211,7 +216,18 @@ export async function runEmbeddedPiAgent(
};
try {
- await applyApiKeyInfo(profileCandidates[profileIndex]);
+ while (profileIndex < profileCandidates.length) {
+ const candidate = profileCandidates[profileIndex];
+ if (candidate && isProfileInCooldown(authStore, candidate)) {
+ profileIndex += 1;
+ continue;
+ }
+ await applyApiKeyInfo(profileCandidates[profileIndex]);
+ break;
+ }
+ if (profileIndex >= profileCandidates.length) {
+ throw new Error(`No available auth profile for ${provider} (all in cooldown or unavailable).`);
+ }
} catch (err) {
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
const advanced = await advanceAuthProfile();
diff --git a/src/telegram/bot-message-context.sender-prefix.test.ts b/src/telegram/bot-message-context.sender-prefix.test.ts
index 12d7b09e5..c7f0e5de9 100644
--- a/src/telegram/bot-message-context.sender-prefix.test.ts
+++ b/src/telegram/bot-message-context.sender-prefix.test.ts
@@ -49,4 +49,96 @@ describe("buildTelegramMessageContext sender prefix", () => {
const body = ctx?.ctxPayload?.Body ?? "";
expect(body).toContain("Alice (42): hello");
});
+
+ it("sets MessageSid from message_id", async () => {
+ const ctx = await buildTelegramMessageContext({
+ primaryCtx: {
+ message: {
+ message_id: 12345,
+ chat: { id: -99, type: "supergroup", title: "Dev Chat" },
+ date: 1700000000,
+ text: "hello",
+ from: { id: 42, first_name: "Alice" },
+ },
+ me: { id: 7, username: "bot" },
+ } as never,
+ allMedia: [],
+ storeAllowFrom: [],
+ options: {},
+ bot: {
+ api: {
+ sendChatAction: vi.fn(),
+ setMessageReaction: vi.fn(),
+ },
+ } as never,
+ cfg: {
+ agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
+ channels: { telegram: {} },
+ messages: { groupChat: { mentionPatterns: [] } },
+ } as never,
+ account: { accountId: "default" } as never,
+ historyLimit: 0,
+ groupHistories: new Map(),
+ dmPolicy: "open",
+ allowFrom: [],
+ groupAllowFrom: [],
+ ackReactionScope: "off",
+ logger: { info: vi.fn() },
+ resolveGroupActivation: () => undefined,
+ resolveGroupRequireMention: () => false,
+ resolveTelegramGroupConfig: () => ({
+ groupConfig: { requireMention: false },
+ topicConfig: undefined,
+ }),
+ });
+
+ expect(ctx).not.toBeNull();
+ expect(ctx?.ctxPayload?.MessageSid).toBe("12345");
+ });
+
+ it("respects messageIdOverride option", async () => {
+ const ctx = await buildTelegramMessageContext({
+ primaryCtx: {
+ message: {
+ message_id: 12345,
+ chat: { id: -99, type: "supergroup", title: "Dev Chat" },
+ date: 1700000000,
+ text: "hello",
+ from: { id: 42, first_name: "Alice" },
+ },
+ me: { id: 7, username: "bot" },
+ } as never,
+ allMedia: [],
+ storeAllowFrom: [],
+ options: { messageIdOverride: "67890" },
+ bot: {
+ api: {
+ sendChatAction: vi.fn(),
+ setMessageReaction: vi.fn(),
+ },
+ } as never,
+ cfg: {
+ agents: { defaults: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" } },
+ channels: { telegram: {} },
+ messages: { groupChat: { mentionPatterns: [] } },
+ } as never,
+ account: { accountId: "default" } as never,
+ historyLimit: 0,
+ groupHistories: new Map(),
+ dmPolicy: "open",
+ allowFrom: [],
+ groupAllowFrom: [],
+ ackReactionScope: "off",
+ logger: { info: vi.fn() },
+ resolveGroupActivation: () => undefined,
+ resolveGroupRequireMention: () => false,
+ resolveTelegramGroupConfig: () => ({
+ groupConfig: { requireMention: false },
+ topicConfig: undefined,
+ }),
+ });
+
+ expect(ctx).not.toBeNull();
+ expect(ctx?.ctxPayload?.MessageSid).toBe("67890");
+ });
});
From 917bcb714e820388eaee0cc1b5c56c0bd62fd5cc Mon Sep 17 00:00:00 2001
From: Tobias Bischoff <711564+tobiasbischoff@users.noreply.github.com>
Date: Thu, 22 Jan 2026 10:29:37 +0100
Subject: [PATCH 03/86] perf(tui): optimize searchable select list filtering
- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort
Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
---
src/tui/components/searchable-select-list.ts | 45 ++++++++++----------
1 file changed, 23 insertions(+), 22 deletions(-)
diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts
index 37ff21ebc..886cc0170 100644
--- a/src/tui/components/searchable-select-list.ts
+++ b/src/tui/components/searchable-select-list.ts
@@ -1,6 +1,5 @@
import {
type Component,
- fuzzyFilter,
getEditorKeybindings,
Input,
isKeyRelease,
@@ -10,7 +9,7 @@ import {
truncateToWidth,
} from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js";
-import { findWordBoundaryIndex } from "./fuzzy-filter.js";
+import { findWordBoundaryIndex, fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string;
@@ -28,6 +27,7 @@ export class SearchableSelectList implements Component {
private maxVisible: number;
private theme: SearchableSelectListTheme;
private searchInput: Input;
+ private regexCache = new Map();
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
@@ -41,6 +41,15 @@ export class SearchableSelectList implements Component {
this.searchInput = new Input();
}
+ private getCachedRegex(pattern: string): RegExp {
+ let regex = this.regexCache.get(pattern);
+ if (!regex) {
+ regex = new RegExp(this.escapeRegex(pattern), "gi");
+ this.regexCache.set(pattern, regex);
+ }
+ return regex;
+ }
+
private updateFilter() {
const query = this.searchInput.getValue().trim();
@@ -59,15 +68,13 @@ export class SearchableSelectList implements Component {
* Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label
- * 3. Exact substring match in description
+ * 3. Exact substring in description
* 4. Fuzzy match (lowest priority)
*/
private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number };
- const exactLabel: ScoredItem[] = [];
- const wordBoundary: ScoredItem[] = [];
- const descriptionMatches: ScoredItem[] = [];
+ const scoredItems: ScoredItem[] = [];
const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) {
@@ -77,38 +84,32 @@ export class SearchableSelectList implements Component {
// Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q);
if (labelIndex !== -1) {
- // Earlier match = better score
- exactLabel.push({ item, score: labelIndex });
+ scoredItems.push({ item, score: labelIndex });
continue;
}
// Tier 2: Word-boundary prefix in label (score 100-199)
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null) {
- wordBoundary.push({ item, score: wordBoundaryIndex });
+ scoredItems.push({ item, score: 100 + wordBoundaryIndex });
continue;
}
// Tier 3: Exact substring in description (score 200-299)
const descIndex = desc.indexOf(q);
if (descIndex !== -1) {
- descriptionMatches.push({ item, score: descIndex });
+ scoredItems.push({ item, score: 200 + descIndex });
continue;
}
// Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item);
}
- exactLabel.sort(this.compareByScore);
- wordBoundary.sort(this.compareByScore);
- descriptionMatches.sort(this.compareByScore);
- const fuzzyMatches = fuzzyFilter(
- fuzzyCandidates,
- query,
- (i) => `${i.label} ${i.description ?? ""}`,
- );
+ scoredItems.sort(this.compareByScore);
+
+ const preparedCandidates = prepareSearchItems(fuzzyCandidates);
+ const fuzzyMatches = fuzzyFilterLower(preparedCandidates, q);
+
return [
- ...exactLabel.map((s) => s.item),
- ...wordBoundary.map((s) => s.item),
- ...descriptionMatches.map((s) => s.item),
+ ...scoredItems.map((s) => s.item),
...fuzzyMatches,
];
}
@@ -140,7 +141,7 @@ export class SearchableSelectList implements Component {
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
let result = text;
for (const token of uniqueTokens) {
- const regex = new RegExp(this.escapeRegex(token), "gi");
+ const regex = this.getCachedRegex(token);
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
}
return result;
From 0873351401bb9406dc621a69bd78206da62da211 Mon Sep 17 00:00:00 2001
From: Robby
Date: Thu, 22 Jan 2026 09:58:07 +0000
Subject: [PATCH 04/86] fix: update token count display after compaction
(#1299)
---
src/agents/pi-embedded-runner/compact.ts | 23 ++++++++++++++++++++-
src/agents/pi-embedded-runner/types.ts | 1 +
src/auto-reply/reply/commands-compact.ts | 26 +++++++++++++++---------
src/auto-reply/reply/session-updates.ts | 22 +++++++++++++++-----
4 files changed, 56 insertions(+), 16 deletions(-)
diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts
index 2aad2431a..53b0ae8c0 100644
--- a/src/agents/pi-embedded-runner/compact.ts
+++ b/src/agents/pi-embedded-runner/compact.ts
@@ -1,7 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
-import { createAgentSession, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
+import {
+ createAgentSession,
+ estimateTokens,
+ SessionManager,
+ SettingsManager,
+} from "@mariozechner/pi-coding-agent";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
@@ -370,6 +375,21 @@ export async function compactEmbeddedPiSession(params: {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
+ // Estimate tokens after compaction by summing token estimates for remaining messages
+ let tokensAfter: number | undefined;
+ try {
+ tokensAfter = 0;
+ for (const message of session.messages) {
+ tokensAfter += estimateTokens(message);
+ }
+ // Sanity check: tokensAfter should be less than tokensBefore
+ if (tokensAfter > result.tokensBefore) {
+ tokensAfter = undefined; // Don't trust the estimate
+ }
+ } catch {
+ // If estimation fails, leave tokensAfter undefined
+ tokensAfter = undefined;
+ }
return {
ok: true,
compacted: true,
@@ -377,6 +397,7 @@ export async function compactEmbeddedPiSession(params: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
+ tokensAfter,
details: result.details,
},
};
diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts
index 56380cd1d..6a1ee1128 100644
--- a/src/agents/pi-embedded-runner/types.ts
+++ b/src/agents/pi-embedded-runner/types.ts
@@ -59,6 +59,7 @@ export type EmbeddedPiCompactResult = {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
+ tokensAfter?: number;
details?: unknown;
};
};
diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts
index da048ec65..3fd47172f 100644
--- a/src/auto-reply/reply/commands-compact.ts
+++ b/src/auto-reply/reply/commands-compact.ts
@@ -83,18 +83,13 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
});
- const totalTokens =
- params.sessionEntry.totalTokens ??
- (params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
- const contextSummary = formatContextUsageShort(
- totalTokens > 0 ? totalTokens : null,
- params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
- );
const compactLabel = result.ok
? result.compacted
- ? result.result?.tokensBefore
- ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
- : "Compacted"
+ ? result.result?.tokensBefore != null && result.result?.tokensAfter != null
+ ? `Compacted (${formatTokenCount(result.result.tokensBefore)} → ${formatTokenCount(result.result.tokensAfter)})`
+ : result.result?.tokensBefore
+ ? `Compacted (${formatTokenCount(result.result.tokensBefore)} before)`
+ : "Compacted"
: "Compaction skipped"
: "Compaction failed";
if (result.ok && result.compacted) {
@@ -103,8 +98,19 @@ export const handleCompactCommand: CommandHandler = async (params) => {
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
+ // Update token counts after compaction
+ tokensAfter: result.result?.tokensAfter,
});
}
+ // Use the post-compaction token count for context summary if available
+ const tokensAfterCompaction = result.result?.tokensAfter;
+ const totalTokens = tokensAfterCompaction ??
+ params.sessionEntry.totalTokens ??
+ (params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
+ const contextSummary = formatContextUsageShort(
+ totalTokens > 0 ? totalTokens : null,
+ params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
+ );
const reason = result.reason?.trim();
const line = reason
? `${compactLabel}: ${reason} • ${contextSummary}`
diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts
index e5ad81d8e..acdadc39c 100644
--- a/src/auto-reply/reply/session-updates.ts
+++ b/src/auto-reply/reply/session-updates.ts
@@ -237,23 +237,35 @@ export async function incrementCompactionCount(params: {
sessionKey?: string;
storePath?: string;
now?: number;
+ /** Token count after compaction - if provided, updates session token counts */
+ tokensAfter?: number;
}): Promise {
- const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now() } = params;
+ const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now(), tokensAfter } = params;
if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined;
const nextCount = (entry.compactionCount ?? 0) + 1;
- sessionStore[sessionKey] = {
- ...entry,
+ // Build update payload with compaction count and optionally updated token counts
+ const updates: Partial = {
compactionCount: nextCount,
updatedAt: now,
};
+ // If tokensAfter is provided, update the cached token counts to reflect post-compaction state
+ if (tokensAfter != null && tokensAfter > 0) {
+ updates.totalTokens = tokensAfter;
+ // Clear input/output breakdown since we only have the total estimate after compaction
+ updates.inputTokens = undefined;
+ updates.outputTokens = undefined;
+ }
+ sessionStore[sessionKey] = {
+ ...entry,
+ ...updates,
+ };
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = {
...store[sessionKey],
- compactionCount: nextCount,
- updatedAt: now,
+ ...updates,
};
});
}
From 57e81d3c2481a9c3830b671e792415d3b29ab74a Mon Sep 17 00:00:00 2001
From: Matt mini
Date: Thu, 22 Jan 2026 12:12:49 +0100
Subject: [PATCH 05/86] Fix: Support path and filePath parameters in message
send action
The message tool accepts path and filePath parameters in its schema,
but these were never converted to mediaUrl, causing local files to
be ignored when sending messages.
Changes:
- src/agents/tools/message-tool.ts: Convert path/filePath to media with file:// URL
- src/infra/outbound/message-action-runner.ts: Allow hydrateSendAttachmentParams for "send" action
Fixes issue where local audio files (and other media) couldn't be sent
via the message tool with the path parameter.
Users can now use:
message({ path: "/tmp/file.ogg" })
message({ filePath: "/tmp/file.ogg" })
---
src/agents/tools/message-tool.ts | 10 ++++++++++
src/infra/outbound/message-action-runner.ts | 2 +-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts
index 657284b52..19cdb122d 100644
--- a/src/agents/tools/message-tool.ts
+++ b/src/agents/tools/message-tool.ts
@@ -340,6 +340,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
+
+ // Handle path and filePath parameters: convert to media with file:// URL
+ if (action === "send" && !params.media) {
+ const filePath =
+ (params.path as string | undefined) || (params.filePath as string | undefined);
+ if (filePath) {
+ params.media = filePath.startsWith("file://") ? filePath : `file://${filePath}`;
+ }
+ }
+
const accountId = readStringParam(params, "accountId") ?? agentAccountId;
const gateway = {
diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts
index dc8aeddf3..aef11c09c 100644
--- a/src/infra/outbound/message-action-runner.ts
+++ b/src/infra/outbound/message-action-runner.ts
@@ -343,7 +343,7 @@ async function hydrateSendAttachmentParams(params: {
action: ChannelMessageActionName;
dryRun?: boolean;
}): Promise {
- if (params.action !== "sendAttachment") return;
+ if (params.action !== "sendAttachment" && params.action !== "send") return;
const mediaHint = readStringParam(params.args, "media", { trim: false });
const fileHint =
From 47e440f73a4920e2687e019e8fcaf3f464628848 Mon Sep 17 00:00:00 2001
From: Jonathan Rhyne
Date: Thu, 22 Jan 2026 08:33:13 -0500
Subject: [PATCH 06/86] fix(slack): remove deprecated filetype field from
files.uploadV2
Slack's files.uploadV2 API no longer supports the filetype field and logs
deprecation warnings when it's included. Slack auto-detects the file type
from the file content, so this field is unnecessary.
This removes the warning:
[WARN] web-api:WebClient filetype is no longer a supported field in files.uploadV2.
---
src/slack/send.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/slack/send.ts b/src/slack/send.ts
index 1f867e9ac..cfc6c4707 100644
--- a/src/slack/send.ts
+++ b/src/slack/send.ts
@@ -94,7 +94,7 @@ async function uploadSlackFile(params: {
file: buffer,
filename: fileName,
...(params.caption ? { initial_comment: params.caption } : {}),
- ...(contentType ? { filetype: contentType } : {}),
+ // Note: filetype is deprecated in files.uploadV2, Slack auto-detects from file content
};
const payload: FilesUploadV2Arguments = params.threadTs
? { ...basePayload, thread_ts: params.threadTs }
From 8b6b97c3f6d6b3f9702a0a0026ad6254aa2aa3d1 Mon Sep 17 00:00:00 2001
From: Jonathan Rhyne
Date: Thu, 22 Jan 2026 08:39:54 -0500
Subject: [PATCH 07/86] docs: add changelog entry for PR #1447
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 489f15890..67b4159ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Control UI: ignore bootstrap identity placeholder text for avatar values and fall back to the default avatar. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
+- Slack: remove deprecated `filetype` field from `files.uploadV2` to eliminate API warnings. (#1447)
## 2026.1.21
From ba824a4b2d29e14e7e3961c578405f92583450a8 Mon Sep 17 00:00:00 2001
From: zerone0x
Date: Fri, 23 Jan 2026 00:45:20 +0800
Subject: [PATCH 08/86] docs(gog): fix invalid service name in auth example
Replace invalid "docs" service with the correct "tasks,people" services
in the setup example. The gog CLI does not have a "docs" service - docs
commands (export/cat) use Drive authentication instead.
Fixes #1433
Co-Authored-By: Claude
---
skills/gog/SKILL.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/skills/gog/SKILL.md b/skills/gog/SKILL.md
index 5f9fc3fee..a8d0b0162 100644
--- a/skills/gog/SKILL.md
+++ b/skills/gog/SKILL.md
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
-- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,sheets,docs`
+- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,tasks,people,sheets`
- `gog auth list`
Common commands
From 495a39b5a9893ced78aa9ea755ff749dcbf3425f Mon Sep 17 00:00:00 2001
From: Dominic Damoah
Date: Thu, 22 Jan 2026 12:02:30 -0500
Subject: [PATCH 09/86] refactor: extract mattermost channel plugin to
extension
Move mattermost channel implementation from core to extensions/mattermost plugin. Extract config schema, group mentions, normalize utilities, and all mattermost-specific logic (accounts, client, monitor, probe, send) into the extension. Update imports to use plugin SDK and local modules. Add channel metadata directly in plugin definition instead of using getChatChannelMeta. Update package.json with channel and install configuration.
---
extensions/mattermost/package.json | 16 +-
extensions/mattermost/src/channel.ts | 54 +++--
extensions/mattermost/src/config-schema.ts | 24 +++
extensions/mattermost/src/group-mentions.ts | 14 ++
.../mattermost/src}/mattermost/accounts.ts | 7 +-
.../mattermost/src}/mattermost/client.ts | 0
.../mattermost/src}/mattermost/index.ts | 0
.../src/mattermost/monitor-helpers.ts | 150 +++++++++++++
.../mattermost/src}/mattermost/monitor.ts | 203 +++++++++---------
.../mattermost/src}/mattermost/probe.ts | 0
.../mattermost/src}/mattermost/send.ts | 19 +-
.../mattermost/src/normalize.ts | 0
.../mattermost/src/onboarding-helpers.ts | 42 ++++
.../mattermost/src/onboarding.ts | 14 +-
extensions/mattermost/src/types.ts | 40 ++++
src/auto-reply/reply/get-reply-run.ts | 3 +-
src/channels/dock.ts | 25 ---
src/channels/plugins/group-mentions.ts | 10 -
src/channels/registry.ts | 11 -
src/infra/outbound/deliver.ts | 9 +-
src/plugin-sdk/index.ts | 17 --
src/plugins/runtime/index.ts | 8 -
src/plugins/runtime/types.ts | 9 -
ui/src/ui/views/channels.ts | 2 +-
24 files changed, 442 insertions(+), 235 deletions(-)
create mode 100644 extensions/mattermost/src/config-schema.ts
create mode 100644 extensions/mattermost/src/group-mentions.ts
rename {src => extensions/mattermost/src}/mattermost/accounts.ts (96%)
rename {src => extensions/mattermost/src}/mattermost/client.ts (100%)
rename {src => extensions/mattermost/src}/mattermost/index.ts (100%)
create mode 100644 extensions/mattermost/src/mattermost/monitor-helpers.ts
rename {src => extensions/mattermost/src}/mattermost/monitor.ts (80%)
rename {src => extensions/mattermost/src}/mattermost/probe.ts (100%)
rename {src => extensions/mattermost/src}/mattermost/send.ts (93%)
rename src/channels/plugins/normalize/mattermost.ts => extensions/mattermost/src/normalize.ts (100%)
create mode 100644 extensions/mattermost/src/onboarding-helpers.ts
rename src/channels/plugins/onboarding/mattermost.ts => extensions/mattermost/src/onboarding.ts (91%)
create mode 100644 extensions/mattermost/src/types.ts
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index 8ba462f45..f98f3c446 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -6,6 +6,20 @@
"clawdbot": {
"extensions": [
"./index.ts"
- ]
+ ],
+ "channel": {
+ "id": "mattermost",
+ "label": "Mattermost",
+ "selectionLabel": "Mattermost (plugin)",
+ "docsPath": "/channels/mattermost",
+ "docsLabel": "mattermost",
+ "blurb": "self-hosted Slack-style chat; install the plugin to enable.",
+ "order": 65
+ },
+ "install": {
+ "npmSpec": "@clawdbot/mattermost",
+ "localPath": "extensions/mattermost",
+ "defaultChoice": "npm"
+ }
}
}
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index 840772a17..b365fc61e 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -3,26 +3,42 @@ import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
- getChatChannelMeta,
- listMattermostAccountIds,
- looksLikeMattermostTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
- normalizeMattermostBaseUrl,
- normalizeMattermostMessagingTarget,
- resolveDefaultMattermostAccountId,
- resolveMattermostAccount,
- resolveMattermostGroupRequireMention,
setAccountEnabledInConfigSection,
- mattermostOnboardingAdapter,
- MattermostConfigSchema,
type ChannelPlugin,
- type ResolvedMattermostAccount,
} from "clawdbot/plugin-sdk";
+import { MattermostConfigSchema } from "./config-schema.js";
+import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
+import {
+ looksLikeMattermostTargetId,
+ normalizeMattermostMessagingTarget,
+} from "./normalize.js";
+import { mattermostOnboardingAdapter } from "./onboarding.js";
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ type ResolvedMattermostAccount,
+} from "./mattermost/accounts.js";
+import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
+import { monitorMattermostProvider } from "./mattermost/monitor.js";
+import { probeMattermost } from "./mattermost/probe.js";
+import { sendMessageMattermost } from "./mattermost/send.js";
import { getMattermostRuntime } from "./runtime.js";
-const meta = getChatChannelMeta("mattermost");
+const meta = {
+ id: "mattermost",
+ label: "Mattermost",
+ selectionLabel: "Mattermost (plugin)",
+ detailLabel: "Mattermost Bot",
+ docsPath: "/channels/mattermost",
+ docsLabel: "mattermost",
+ blurb: "self-hosted Slack-style chat; install the plugin to enable.",
+ systemImage: "bubble.left.and.bubble.right",
+ order: 65,
+} as const;
export const mattermostPlugin: ChannelPlugin = {
id: "mattermost",
@@ -96,8 +112,7 @@ export const mattermostPlugin: ChannelPlugin = {
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
- const send =
- deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const send = deps?.sendMattermost ?? sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
@@ -105,8 +120,7 @@ export const mattermostPlugin: ChannelPlugin = {
return { channel: "mattermost", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
- const send =
- deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const send = deps?.sendMattermost ?? sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
@@ -144,11 +158,7 @@ export const mattermostPlugin: ChannelPlugin = {
if (!token || !baseUrl) {
return { ok: false, error: "bot token or baseUrl missing" };
}
- return await getMattermostRuntime().channel.mattermost.probeMattermost(
- baseUrl,
- token,
- timeoutMs,
- );
+ return await probeMattermost(baseUrl, token, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
@@ -256,7 +266,7 @@ export const mattermostPlugin: ChannelPlugin = {
botTokenSource: account.botTokenSource,
});
ctx.log?.info(`[${account.accountId}] starting channel`);
- return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
+ return monitorMattermostProvider({
botToken: account.botToken ?? undefined,
baseUrl: account.baseUrl ?? undefined,
accountId: account.accountId,
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
new file mode 100644
index 000000000..3cbecaf34
--- /dev/null
+++ b/extensions/mattermost/src/config-schema.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
+
+const MattermostAccountSchema = z
+ .object({
+ name: z.string().optional(),
+ capabilities: z.array(z.string()).optional(),
+ enabled: z.boolean().optional(),
+ configWrites: z.boolean().optional(),
+ botToken: z.string().optional(),
+ baseUrl: z.string().optional(),
+ chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
+ oncharPrefixes: z.array(z.string()).optional(),
+ requireMention: z.boolean().optional(),
+ textChunkLimit: z.number().int().positive().optional(),
+ blockStreaming: z.boolean().optional(),
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+ })
+ .strict();
+
+export const MattermostConfigSchema = MattermostAccountSchema.extend({
+ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
+});
diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts
new file mode 100644
index 000000000..773e655ff
--- /dev/null
+++ b/extensions/mattermost/src/group-mentions.ts
@@ -0,0 +1,14 @@
+import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
+
+import { resolveMattermostAccount } from "./mattermost/accounts.js";
+
+export function resolveMattermostGroupRequireMention(
+ params: ChannelGroupContext,
+): boolean | undefined {
+ const account = resolveMattermostAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ if (typeof account.requireMention === "boolean") return account.requireMention;
+ return true;
+}
diff --git a/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts
similarity index 96%
rename from src/mattermost/accounts.ts
rename to extensions/mattermost/src/mattermost/accounts.ts
index 08ffa2f94..e75f34593 100644
--- a/src/mattermost/accounts.ts
+++ b/extensions/mattermost/src/mattermost/accounts.ts
@@ -1,6 +1,7 @@
-import type { ClawdbotConfig } from "../config/config.js";
-import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js";
-import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
+import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
export type MattermostTokenSource = "env" | "config" | "none";
diff --git a/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts
similarity index 100%
rename from src/mattermost/client.ts
rename to extensions/mattermost/src/mattermost/client.ts
diff --git a/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts
similarity index 100%
rename from src/mattermost/index.ts
rename to extensions/mattermost/src/mattermost/index.ts
diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts
new file mode 100644
index 000000000..8c68a4f25
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts
@@ -0,0 +1,150 @@
+import { Buffer } from "node:buffer";
+
+import type WebSocket from "ws";
+
+import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+
+export type ResponsePrefixContext = {
+ model?: string;
+ modelFull?: string;
+ provider?: string;
+ thinkingLevel?: string;
+ identityName?: string;
+};
+
+export function extractShortModelName(fullModel: string): string {
+ const slash = fullModel.lastIndexOf("/");
+ const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
+ return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
+}
+
+export function formatInboundFromLabel(params: {
+ isGroup: boolean;
+ groupLabel?: string;
+ groupId?: string;
+ directLabel: string;
+ directId?: string;
+ groupFallback?: string;
+}): string {
+ if (params.isGroup) {
+ const label = params.groupLabel?.trim() || params.groupFallback || "Group";
+ const id = params.groupId?.trim();
+ return id ? `${label} id:${id}` : label;
+ }
+
+ const directLabel = params.directLabel.trim();
+ const directId = params.directId?.trim();
+ if (!directId || directId === directLabel) return directLabel;
+ return `${directLabel} id:${directId}`;
+}
+
+type DedupeCache = {
+ check: (key: string | undefined | null, now?: number) => boolean;
+};
+
+export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
+ const ttlMs = Math.max(0, options.ttlMs);
+ const maxSize = Math.max(0, Math.floor(options.maxSize));
+ const cache = new Map();
+
+ const touch = (key: string, now: number) => {
+ cache.delete(key);
+ cache.set(key, now);
+ };
+
+ const prune = (now: number) => {
+ const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
+ if (cutoff !== undefined) {
+ for (const [entryKey, entryTs] of cache) {
+ if (entryTs < cutoff) {
+ cache.delete(entryKey);
+ }
+ }
+ }
+ if (maxSize <= 0) {
+ cache.clear();
+ return;
+ }
+ while (cache.size > maxSize) {
+ const oldestKey = cache.keys().next().value as string | undefined;
+ if (!oldestKey) break;
+ cache.delete(oldestKey);
+ }
+ };
+
+ return {
+ check: (key, now = Date.now()) => {
+ if (!key) return false;
+ const existing = cache.get(key);
+ if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
+ touch(key, now);
+ return true;
+ }
+ touch(key, now);
+ prune(now);
+ return false;
+ },
+ };
+}
+
+export function rawDataToString(
+ data: WebSocket.RawData,
+ encoding: BufferEncoding = "utf8",
+): string {
+ if (typeof data === "string") return data;
+ if (Buffer.isBuffer(data)) return data.toString(encoding);
+ if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
+ if (data instanceof ArrayBuffer) {
+ return Buffer.from(data).toString(encoding);
+ }
+ return Buffer.from(String(data)).toString(encoding);
+}
+
+function normalizeAgentId(value: string | undefined | null): string {
+ const trimmed = (value ?? "").trim();
+ if (!trimmed) return "main";
+ if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
+ return (
+ trimmed
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+/, "")
+ .replace(/-+$/, "")
+ .slice(0, 64) || "main"
+ );
+}
+
+type AgentEntry = NonNullable["list"]>[number];
+
+function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
+ const list = cfg.agents?.list;
+ if (!Array.isArray(list)) return [];
+ return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
+}
+
+function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
+ const id = normalizeAgentId(agentId);
+ return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
+}
+
+export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
+ const entry = resolveAgentEntry(cfg, agentId);
+ return entry?.identity?.name?.trim() || undefined;
+}
+
+export function resolveThreadSessionKeys(params: {
+ baseSessionKey: string;
+ threadId?: string | null;
+ parentSessionKey?: string;
+ useSuffix?: boolean;
+}): { sessionKey: string; parentSessionKey?: string } {
+ const threadId = (params.threadId ?? "").trim();
+ if (!threadId) {
+ return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
+ }
+ const useSuffix = params.useSuffix ?? true;
+ const sessionKey = useSuffix
+ ? `${params.baseSessionKey}:thread:${threadId}`
+ : params.baseSessionKey;
+ return { sessionKey, parentSessionKey: params.parentSessionKey };
+}
diff --git a/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
similarity index 80%
rename from src/mattermost/monitor.ts
rename to extensions/mattermost/src/mattermost/monitor.ts
index fb8bd00db..7c0d98fca 100644
--- a/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -1,52 +1,21 @@
import WebSocket from "ws";
-import {
- resolveEffectiveMessagesConfig,
- resolveHumanDelayConfig,
- resolveIdentityName,
-} from "../agents/identity.js";
-import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
-import { hasControlCommand } from "../auto-reply/command-detection.js";
-import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js";
-import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js";
-import {
- createInboundDebouncer,
- resolveInboundDebounceMs,
-} from "../auto-reply/inbound-debounce.js";
-import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
-import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
+import type {
+ ChannelAccountSnapshot,
+ ClawdbotConfig,
+ ReplyPayload,
+ RuntimeEnv,
+} from "clawdbot/plugin-sdk";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
+ resolveChannelMediaMaxBytes,
type HistoryEntry,
-} from "../auto-reply/reply/history.js";
-import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
-import {
- extractShortModelName,
- type ResponsePrefixContext,
-} from "../auto-reply/reply/response-prefix-template.js";
-import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
-import type { ReplyPayload } from "../auto-reply/types.js";
-import type { ClawdbotConfig } from "../config/config.js";
-import { loadConfig } from "../config/config.js";
-import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
-import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
-import { createDedupeCache } from "../infra/dedupe.js";
-import { rawDataToString } from "../infra/ws.js";
-import { recordChannelActivity } from "../infra/channel-activity.js";
-import { enqueueSystemEvent } from "../infra/system-events.js";
-import { getChildLogger } from "../logging.js";
-import { mediaKindFromMime, type MediaKind } from "../media/constants.js";
-import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js";
-import { saveMediaBuffer } from "../media/store.js";
-import { resolveAgentRoute } from "../routing/resolve-route.js";
-import { resolveThreadSessionKeys } from "../routing/session-key.js";
-import type { RuntimeEnv } from "../runtime.js";
-import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
-import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
-import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
+} from "clawdbot/plugin-sdk";
+
+import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -59,6 +28,15 @@ import {
type MattermostPost,
type MattermostUser,
} from "./client.js";
+import {
+ createDedupeCache,
+ extractShortModelName,
+ formatInboundFromLabel,
+ rawDataToString,
+ resolveIdentityName,
+ resolveThreadSessionKeys,
+ type ResponsePrefixContext,
+} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
export type MonitorMattermostOpts = {
@@ -71,6 +49,9 @@ export type MonitorMattermostOpts = {
statusSink?: (patch: Partial) => void;
};
+type FetchLike = typeof fetch;
+type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
+
type MattermostEventPayload = {
event?: string;
data?: {
@@ -208,8 +189,9 @@ function buildMattermostWsUrl(baseUrl: string): string {
}
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise {
+ const core = getMattermostRuntime();
const runtime = resolveRuntime(opts);
- const cfg = opts.config ?? loadConfig();
+ const cfg = opts.config ?? core.config.loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
@@ -235,7 +217,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const channelCache = new Map();
const userCache = new Map();
- const logger = getChildLogger({ module: "mattermost" });
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const logVerboseMessage = (message: string) => {
+ if (!core.logging.shouldLogVerbose()) return;
+ logger.debug?.(message);
+ };
const mediaMaxBytes =
resolveChannelMediaMaxBytes({
cfg,
@@ -262,13 +248,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
- const fetched = await fetchRemoteMedia({
+ const fetched = await core.channel.media.fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
fetchImpl: fetchWithAuth,
filePathHint: fileId,
maxBytes: mediaMaxBytes,
});
- const saved = await saveMediaBuffer(
+ const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? undefined,
"inbound",
@@ -278,7 +264,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
out.push({
path: saved.path,
contentType,
- kind: mediaKindFromMime(contentType),
+ kind: core.media.mediaKindFromMime(contentType),
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
@@ -366,7 +352,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
- const route = resolveAgentRoute({
+ const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
@@ -387,12 +373,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const sessionKey = threadKeys.sessionKey;
const historyKey = kind === "dm" ? null : sessionKey;
- const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
const rawText = post.message?.trim() || "";
const wasMentioned =
kind !== "dm" &&
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
- matchesMentionPatterns(rawText, mentionRegexes));
+ core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
const pendingBody =
rawText ||
(post.file_ids?.length
@@ -416,11 +402,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
};
- const allowTextCommands = shouldHandleTextCommands({
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
- const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
+ const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
const oncharResult = oncharEnabled
@@ -456,7 +442,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) return;
- recordChannelActivity({
+ core.channel.activity.record({
channel: "mattermost",
accountId: account.accountId,
direction: "inbound",
@@ -476,13 +462,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
kind === "dm"
? `Mattermost DM from ${senderName}`
: `Mattermost message in ${roomLabel} from ${senderName}`;
- enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
});
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
- const body = formatInboundEnvelope({
+ const body = core.channel.reply.formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
@@ -498,7 +484,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
- formatInboundEnvelope({
+ core.channel.reply.formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: entry.timestamp,
@@ -513,11 +499,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
const mediaPayload = buildMattermostMediaPayload(mediaList);
- const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
+ const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
authorizers: [],
});
- const ctxPayload = finalizeInboundContext({
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
RawBody: bodyText,
CommandBody: bodyText,
@@ -557,10 +543,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (kind === "dm") {
const sessionCfg = cfg.session;
- const storePath = resolveStorePath(sessionCfg?.store, {
+ const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
- await updateLastRoute({
+ await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
@@ -571,14 +557,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
}
- if (shouldLogVerbose()) {
- const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
- logVerbose(
- `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
- );
- }
+ const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
+ logVerboseMessage(
+ `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
+ );
- const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
+ const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
@@ -586,43 +570,45 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
identityName: resolveIdentityName(cfg, route.agentId),
};
- const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
- responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
- responsePrefixContextProvider: () => prefixContext,
- humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
- deliver: async (payload: ReplyPayload) => {
- const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const text = payload.text ?? "";
- if (mediaUrls.length === 0) {
- const chunks = chunkMarkdownText(text, textLimit);
- for (const chunk of chunks.length > 0 ? chunks : [text]) {
- if (!chunk) continue;
- await sendMessageMattermost(to, chunk, {
- accountId: account.accountId,
- replyToId: threadRootId,
- });
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ core.channel.reply.createReplyDispatcherWithTyping({
+ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
+ .responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
+ deliver: async (payload: ReplyPayload) => {
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+ if (mediaUrls.length === 0) {
+ const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
+ if (!chunk) continue;
+ await sendMessageMattermost(to, chunk, {
+ accountId: account.accountId,
+ replyToId: threadRootId,
+ });
+ }
+ } else {
+ let first = true;
+ for (const mediaUrl of mediaUrls) {
+ const caption = first ? text : "";
+ first = false;
+ await sendMessageMattermost(to, caption, {
+ accountId: account.accountId,
+ mediaUrl,
+ replyToId: threadRootId,
+ });
+ }
}
- } else {
- let first = true;
- for (const mediaUrl of mediaUrls) {
- const caption = first ? text : "";
- first = false;
- await sendMessageMattermost(to, caption, {
- accountId: account.accountId,
- mediaUrl,
- replyToId: threadRootId,
- });
- }
- }
- runtime.log?.(`delivered reply to ${to}`);
- },
- onError: (err, info) => {
- runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`));
- },
- onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
- });
+ runtime.log?.(`delivered reply to ${to}`);
+ },
+ onError: (err, info) => {
+ runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
+ },
+ onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ });
- await dispatchReplyFromConfig({
+ await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -644,8 +630,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
};
- const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
- const debouncer = createInboundDebouncer<{
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
+ cfg,
+ channel: "mattermost",
+ });
+ const debouncer = core.channel.debounce.createInboundDebouncer<{
post: MattermostPost;
payload: MattermostEventPayload;
}>({
@@ -664,7 +653,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
const text = entry.post.message?.trim() ?? "";
if (!text) return false;
- return !hasControlCommand(text, cfg);
+ return !core.channel.text.hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
@@ -686,7 +675,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
},
onError: (err) => {
- runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
+ runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
},
});
@@ -739,7 +728,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
try {
await debouncer.enqueue({ post, payload });
} catch (err) {
- runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
+ runtime.error?.(`mattermost handler failed: ${String(err)}`);
}
});
@@ -758,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
ws.on("error", (err) => {
- runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
+ runtime.error?.(`mattermost websocket error: ${String(err)}`);
opts.statusSink?.({
lastError: String(err),
});
diff --git a/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts
similarity index 100%
rename from src/mattermost/probe.ts
rename to extensions/mattermost/src/mattermost/probe.ts
diff --git a/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts
similarity index 93%
rename from src/mattermost/send.ts
rename to extensions/mattermost/src/mattermost/send.ts
index 40f038cc0..f5b22c768 100644
--- a/src/mattermost/send.ts
+++ b/extensions/mattermost/src/mattermost/send.ts
@@ -1,7 +1,4 @@
-import { loadConfig } from "../config/config.js";
-import { logVerbose, shouldLogVerbose } from "../globals.js";
-import { recordChannelActivity } from "../infra/channel-activity.js";
-import { loadWebMedia } from "../web/media.js";
+import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -34,6 +31,8 @@ type MattermostTarget =
const botUserCache = new Map();
const userByNameCache = new Map();
+const getCore = () => getMattermostRuntime();
+
function cacheKey(baseUrl: string, token: string): string {
return `${baseUrl}::${token}`;
}
@@ -129,7 +128,9 @@ export async function sendMessageMattermost(
text: string,
opts: MattermostSendOpts = {},
): Promise {
- const cfg = loadConfig();
+ const core = getCore();
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const cfg = core.config.loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
@@ -161,7 +162,7 @@ export async function sendMessageMattermost(
const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) {
try {
- const media = await loadWebMedia(mediaUrl);
+ const media = await core.media.loadWebMedia(mediaUrl);
const fileInfo = await uploadMattermostFile(client, {
channelId,
buffer: media.buffer,
@@ -171,8 +172,8 @@ export async function sendMessageMattermost(
fileIds = [fileInfo.id];
} catch (err) {
uploadError = err instanceof Error ? err : new Error(String(err));
- if (shouldLogVerbose()) {
- logVerbose(
+ if (core.logging.shouldLogVerbose()) {
+ logger.debug?.(
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
);
}
@@ -194,7 +195,7 @@ export async function sendMessageMattermost(
fileIds,
});
- recordChannelActivity({
+ core.channel.activity.record({
channel: "mattermost",
accountId: account.accountId,
direction: "outbound",
diff --git a/src/channels/plugins/normalize/mattermost.ts b/extensions/mattermost/src/normalize.ts
similarity index 100%
rename from src/channels/plugins/normalize/mattermost.ts
rename to extensions/mattermost/src/normalize.ts
diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts
new file mode 100644
index 000000000..8a5d1f585
--- /dev/null
+++ b/extensions/mattermost/src/onboarding-helpers.ts
@@ -0,0 +1,42 @@
+import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+type PromptAccountIdParams = {
+ cfg: ClawdbotConfig;
+ prompter: WizardPrompter;
+ label: string;
+ currentId?: string;
+ listAccountIds: (cfg: ClawdbotConfig) => string[];
+ defaultAccountId: string;
+};
+
+export async function promptAccountId(params: PromptAccountIdParams): Promise {
+ const existingIds = params.listAccountIds(params.cfg);
+ const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
+ const choice = (await params.prompter.select({
+ message: `${params.label} account`,
+ options: [
+ ...existingIds.map((id) => ({
+ value: id,
+ label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
+ })),
+ { value: "__new__", label: "Add a new account" },
+ ],
+ initialValue: initial,
+ })) as string;
+
+ if (choice !== "__new__") return normalizeAccountId(choice);
+
+ const entered = await params.prompter.text({
+ message: `New ${params.label} account id`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const normalized = normalizeAccountId(String(entered));
+ if (String(entered).trim() !== normalized) {
+ await params.prompter.note(
+ `Normalized account id to "${normalized}".`,
+ `${params.label} account`,
+ );
+ }
+ return normalized;
+}
diff --git a/src/channels/plugins/onboarding/mattermost.ts b/extensions/mattermost/src/onboarding.ts
similarity index 91%
rename from src/channels/plugins/onboarding/mattermost.ts
rename to extensions/mattermost/src/onboarding.ts
index 3c7ffe2db..431c648ae 100644
--- a/src/channels/plugins/onboarding/mattermost.ts
+++ b/extensions/mattermost/src/onboarding.ts
@@ -1,14 +1,12 @@
-import type { ClawdbotConfig } from "../../../config/config.js";
+import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
-} from "../../../mattermost/accounts.js";
-import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
-import { formatDocsLink } from "../../../terminal/links.js";
-import type { WizardPrompter } from "../../../wizard/prompts.js";
-import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
-import { promptAccountId } from "./helpers.js";
+} from "./mattermost/accounts.js";
+import { promptAccountId } from "./onboarding-helpers.js";
const channel = "mattermost" as const;
@@ -19,7 +17,7 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise {
"2) Create a bot + copy its token",
"3) Use your server base URL (e.g., https://chat.example.com)",
"Tip: the bot must be a member of any channel you want it to monitor.",
- `Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`,
+ "Docs: https://docs.clawd.bot/channels/mattermost",
].join("\n"),
"Mattermost bot token",
);
diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts
new file mode 100644
index 000000000..43e509763
--- /dev/null
+++ b/extensions/mattermost/src/types.ts
@@ -0,0 +1,40 @@
+import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
+
+export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
+
+export type MattermostAccountConfig = {
+ /** Optional display name for this account (used in CLI/UI lists). */
+ name?: string;
+ /** Optional provider capability tags used for agent/runtime guidance. */
+ capabilities?: string[];
+ /** Allow channel-initiated config writes (default: true). */
+ configWrites?: boolean;
+ /** If false, do not start this Mattermost account. Default: true. */
+ enabled?: boolean;
+ /** Bot token for Mattermost. */
+ botToken?: string;
+ /** Base URL for the Mattermost server (e.g., https://chat.example.com). */
+ baseUrl?: string;
+ /**
+ * Controls when channel messages trigger replies.
+ * - "oncall": only respond when mentioned
+ * - "onmessage": respond to every channel message
+ * - "onchar": respond when a trigger character prefixes the message
+ */
+ chatmode?: MattermostChatMode;
+ /** Prefix characters that trigger onchar mode (default: [">", "!"]). */
+ oncharPrefixes?: string[];
+ /** Require @mention to respond in channels. Default: true. */
+ requireMention?: boolean;
+ /** Outbound text chunk size (chars). Default: 4000. */
+ textChunkLimit?: number;
+ /** Disable block streaming for this account. */
+ blockStreaming?: boolean;
+ /** Merge streamed block replies before sending. */
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+};
+
+export type MattermostConfig = {
+ /** Optional per-account Mattermost configuration (multi-account). */
+ accounts?: Record;
+} & MattermostAccountConfig;
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index a2adbf312..524b6ddaf 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -159,8 +159,7 @@ export async function runPreparedReply(
const isGroupChat = sessionCtx.ChatType === "group";
const originatingChannel =
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
- const wasMentioned =
- ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
+ const wasMentioned = ctx.WasMentioned === true;
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
diff --git a/src/channels/dock.ts b/src/channels/dock.ts
index 469880482..81b07c36a 100644
--- a/src/channels/dock.ts
+++ b/src/channels/dock.ts
@@ -12,7 +12,6 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
- resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -231,30 +230,6 @@ const DOCKS: Record = {
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},
- mattermost: {
- id: "mattermost",
- capabilities: {
- chatTypes: ["direct", "channel", "group", "thread"],
- media: true,
- threads: true,
- },
- outbound: { textChunkLimit: 4000 },
- streaming: {
- blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
- },
- groups: {
- resolveRequireMention: resolveMattermostGroupRequireMention,
- },
- threading: {
- buildToolContext: ({ context, hasRepliedRef }) => ({
- currentChannelId: context.To?.startsWith("channel:")
- ? context.To.slice("channel:".length)
- : undefined,
- currentThreadTs: context.ReplyToId,
- hasRepliedRef,
- }),
- },
- },
signal: {
id: "signal",
capabilities: {
diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts
index bb7a111e8..79dfa0320 100644
--- a/src/channels/plugins/group-mentions.ts
+++ b/src/channels/plugins/group-mentions.ts
@@ -1,7 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
-import { resolveMattermostAccount } from "../../mattermost/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -185,15 +184,6 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
return true;
}
-export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean {
- const account = resolveMattermostAccount({
- cfg: params.cfg,
- accountId: params.accountId,
- });
- if (typeof account.requireMention === "boolean") return account.requireMention;
- return true;
-}
-
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
diff --git a/src/channels/registry.ts b/src/channels/registry.ts
index 25fb13502..52e7a5f01 100644
--- a/src/channels/registry.ts
+++ b/src/channels/registry.ts
@@ -9,7 +9,6 @@ export const CHAT_CHANNEL_ORDER = [
"whatsapp",
"discord",
"slack",
- "mattermost",
"signal",
"imessage",
] as const;
@@ -68,16 +67,6 @@ const CHAT_CHANNEL_META: Record = {
blurb: "supported (Socket Mode).",
systemImage: "number",
},
- mattermost: {
- id: "mattermost",
- label: "Mattermost",
- selectionLabel: "Mattermost (Bot Token)",
- detailLabel: "Mattermost Bot",
- docsPath: "/channels/mattermost",
- docsLabel: "mattermost",
- blurb: "self-hosted Slack-style chat (bot token + URL).",
- systemImage: "bubble.left.and.bubble.right",
- },
signal: {
id: "signal",
label: "Signal",
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 74caa18c6..2d874d7e9 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -6,7 +6,6 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
-import type { sendMessageMattermost } from "../../mattermost/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
@@ -29,12 +28,18 @@ type SendMatrixMessage = (
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
) => Promise<{ messageId: string; roomId: string }>;
+type SendMattermostMessage = (
+ to: string,
+ text: string,
+ opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
+) => Promise<{ messageId: string; channelId: string }>;
+
export type OutboundSendDeps = {
sendWhatsApp?: typeof sendMessageWhatsApp;
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
- sendMattermost?: typeof sendMessageMattermost;
+ sendMattermost?: SendMattermostMessage;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index ef8f22d55..1da3650fe 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -81,7 +81,6 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
- MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -121,7 +120,6 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
- resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -241,21 +239,6 @@ export {
} from "../channels/plugins/normalize/slack.js";
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
-// Channel: Mattermost
-export {
- listEnabledMattermostAccounts,
- listMattermostAccountIds,
- resolveDefaultMattermostAccountId,
- resolveMattermostAccount,
- type ResolvedMattermostAccount,
-} from "../mattermost/accounts.js";
-export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
-export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
-export {
- looksLikeMattermostTargetId,
- normalizeMattermostMessagingTarget,
-} from "../channels/plugins/normalize/mattermost.js";
-
// Channel: Telegram
export {
listTelegramAccountIds,
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index e564ad2f8..4765c71c7 100644
--- a/src/plugins/runtime/index.ts
+++ b/src/plugins/runtime/index.ts
@@ -57,9 +57,6 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
-import { monitorMattermostProvider } from "../../mattermost/monitor.js";
-import { probeMattermost } from "../../mattermost/probe.js";
-import { sendMessageMattermost } from "../../mattermost/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
@@ -233,11 +230,6 @@ export function createPluginRuntime(): PluginRuntime {
monitorSlackProvider,
handleSlackAction,
},
- mattermost: {
- probeMattermost,
- sendMessageMattermost,
- monitorMattermostProvider,
- },
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 31350693c..089e20c37 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -98,10 +98,6 @@ type ResolveSlackUserAllowlist =
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
-type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost;
-type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost;
-type MonitorMattermostProvider =
- typeof import("../../mattermost/monitor.js").monitorMattermostProvider;
type AuditTelegramGroupMembership =
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
type CollectTelegramUnmentionedGroupIds =
@@ -246,11 +242,6 @@ export type PluginRuntime = {
monitorSlackProvider: MonitorSlackProvider;
handleSlackAction: HandleSlackAction;
};
- mattermost: {
- probeMattermost: ProbeMattermost;
- sendMessageMattermost: SendMessageMattermost;
- monitorMattermostProvider: MonitorMattermostProvider;
- };
telegram: {
auditGroupMembership: AuditTelegramGroupMembership;
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts
index 96a6b8556..d9f148764 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -101,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
- return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
+ return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
}
function renderChannel(
From 9d09a7879c2e52753e00ce89a3a00fb6c562a0ff Mon Sep 17 00:00:00 2001
From: Dave Lauer
Date: Thu, 22 Jan 2026 12:09:27 -0500
Subject: [PATCH 10/86] fix(ui): allow relative URLs in avatar validation
The isAvatarUrl check only accepted http://, https://, or data: URLs,
but the /avatar/{agentId} endpoint returns relative paths like /avatar/main.
This caused local file avatars to display as text instead of images.
Fixes avatar display for locally configured avatar files.
---
ui/src/ui/chat/grouped-render.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts
index 6a36c7898..408637082 100644
--- a/ui/src/ui/chat/grouped-render.ts
+++ b/ui/src/ui/chat/grouped-render.ts
@@ -158,7 +158,8 @@ function renderAvatar(
function isAvatarUrl(value: string): boolean {
return (
/^https?:\/\//i.test(value) ||
- /^data:image\//i.test(value)
+ /^data:image\//i.test(value) ||
+ /^\//.test(value) // Relative paths from avatar endpoint
);
}
From 8b3cb373d40390927c11a00ff11d9b9c9e6da2ba Mon Sep 17 00:00:00 2001
From: Dominic Damoah
Date: Thu, 22 Jan 2026 12:11:05 -0500
Subject: [PATCH 11/86] fix: remove unused originatingChannel variable
Remove unused originatingChannel variable from runPreparedReply function that was assigned but never referenced.
---
src/auto-reply/reply/get-reply-run.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index 524b6ddaf..28c9e24cb 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -157,8 +157,6 @@ export async function runPreparedReply(
const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group";
- const originatingChannel =
- (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
const wasMentioned = ctx.WasMentioned === true;
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({
From 768d5ccafe171ebf7235026a93b3f3514d6e07d2 Mon Sep 17 00:00:00 2001
From: Robby
Date: Thu, 22 Jan 2026 17:47:52 +0000
Subject: [PATCH 12/86] style: fix formatting
---
src/auto-reply/reply/commands-compact.ts | 3 ++-
src/auto-reply/reply/session-updates.ts | 9 ++++++++-
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/src/auto-reply/reply/commands-compact.ts b/src/auto-reply/reply/commands-compact.ts
index 3fd47172f..7e3f0d960 100644
--- a/src/auto-reply/reply/commands-compact.ts
+++ b/src/auto-reply/reply/commands-compact.ts
@@ -104,7 +104,8 @@ export const handleCompactCommand: CommandHandler = async (params) => {
}
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
- const totalTokens = tokensAfterCompaction ??
+ const totalTokens =
+ tokensAfterCompaction ??
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts
index acdadc39c..970a714d0 100644
--- a/src/auto-reply/reply/session-updates.ts
+++ b/src/auto-reply/reply/session-updates.ts
@@ -240,7 +240,14 @@ export async function incrementCompactionCount(params: {
/** Token count after compaction - if provided, updates session token counts */
tokensAfter?: number;
}): Promise {
- const { sessionEntry, sessionStore, sessionKey, storePath, now = Date.now(), tokensAfter } = params;
+ const {
+ sessionEntry,
+ sessionStore,
+ sessionKey,
+ storePath,
+ now = Date.now(),
+ tokensAfter,
+ } = params;
if (!sessionStore || !sessionKey) return undefined;
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) return undefined;
From 654b6a943bfe4bd6bf404140256520c305641490 Mon Sep 17 00:00:00 2001
From: Ameno Osman
Date: Thu, 22 Jan 2026 11:15:51 -0800
Subject: [PATCH 13/86] fix(node): use node run for node daemon
---
src/daemon/program-args.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts
index b9f41feca..e606ae370 100644
--- a/src/daemon/program-args.ts
+++ b/src/daemon/program-args.ts
@@ -237,7 +237,7 @@ export async function resolveNodeProgramArguments(params: {
runtime?: GatewayRuntimePreference;
nodePath?: string;
}): Promise {
- const args = ["node", "start", "--host", params.host, "--port", String(params.port)];
+ const args = ["node", "run", "--host", params.host, "--port", String(params.port)];
if (params.tls || params.tlsFingerprint) args.push("--tls");
if (params.tlsFingerprint) args.push("--tls-fingerprint", params.tlsFingerprint);
if (params.nodeId) args.push("--node-id", params.nodeId);
From ffca65d15fe0d77feefe6da7115f9d5a3dac35cd Mon Sep 17 00:00:00 2001
From: Dave Lauer
Date: Thu, 22 Jan 2026 15:16:31 -0500
Subject: [PATCH 14/86] fix(gateway): resolve local avatars to URL in HTML
injection and RPC
The frontend fix alone wasn't enough because:
1. serveIndexHtml() was injecting the raw avatar filename into HTML
2. agent.identity.get RPC was returning raw filename, overwriting the
HTML-injected value
Now both paths resolve local file avatars (*.png, *.jpg, etc.) to the
/avatar/{agentId} endpoint URL.
---
src/gateway/control-ui.ts | 13 ++++++++++++-
src/gateway/server-methods/agent.ts | 13 ++++++++++++-
2 files changed, 24 insertions(+), 2 deletions(-)
diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts
index 8d2d881b1..13ee309de 100644
--- a/src/gateway/control-ui.ts
+++ b/src/gateway/control-ui.ts
@@ -206,11 +206,22 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
+function looksLikeLocalAvatarPath(value: string | undefined): boolean {
+ if (!value) return false;
+ if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return false;
+ return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
+}
+
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
+ // Resolve local file avatars to /avatar/{agentId} URL
+ let avatarValue = identity.avatar;
+ if (looksLikeLocalAvatarPath(avatarValue) && identity.agentId) {
+ avatarValue = buildAvatarUrl(basePath, identity.agentId);
+ }
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
@@ -218,7 +229,7 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
injectControlUiConfig(raw, {
basePath,
assistantName: identity.name,
- assistantAvatar: identity.avatar,
+ assistantAvatar: avatarValue,
}),
);
}
diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts
index aaeb3c272..e99d0ecdf 100644
--- a/src/gateway/server-methods/agent.ts
+++ b/src/gateway/server-methods/agent.ts
@@ -407,7 +407,18 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId });
- respond(true, identity, undefined);
+ // Resolve local file avatars to /avatar/{agentId} URL
+ let avatarValue = identity.avatar;
+ if (
+ avatarValue &&
+ !/^https?:\/\//i.test(avatarValue) &&
+ !/^data:image\//i.test(avatarValue) &&
+ /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(avatarValue) &&
+ identity.agentId
+ ) {
+ avatarValue = `/avatar/${identity.agentId}`;
+ }
+ respond(true, { ...identity, avatar: avatarValue }, undefined);
},
"agent.wait": async ({ params, respond }) => {
if (!validateAgentWaitParams(params)) {
From 773dad256e260f90d1d3113e1135f1f8362c7c1f Mon Sep 17 00:00:00 2001
From: Al
Date: Thu, 22 Jan 2026 15:22:20 -0500
Subject: [PATCH 15/86] fix(session-memory): suppress user-visible confirmation
message
The session-memory hook saves session context to memory files when /new is run,
which is useful internal housekeeping. However, the confirmation message that
was displayed to users (showing the file path) leaked implementation details.
This change removes the user-visible message while keeping the console.log
for debugging purposes. The hook continues to save session context silently.
---
src/hooks/bundled/session-memory/handler.ts | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts
index bcce11cea..14b486fcc 100644
--- a/src/hooks/bundled/session-memory/handler.ts
+++ b/src/hooks/bundled/session-memory/handler.ts
@@ -160,11 +160,9 @@ const saveSessionToMemory: HookHandler = async (event) => {
await fs.writeFile(memoryFilePath, entry, "utf-8");
console.log("[session-memory] Memory file written successfully");
- // Send confirmation message to user with filename
+ // Log completion (but don't send user-visible confirmation - it's internal housekeeping)
const relPath = memoryFilePath.replace(os.homedir(), "~");
- const confirmMsg = `💾 Session context saved to memory before reset.\n📄 ${relPath}`;
- event.messages.push(confirmMsg);
- console.log("[session-memory] Confirmation message queued:", confirmMsg);
+ console.log(`[session-memory] Session context saved to ${relPath}`);
} catch (err) {
console.error(
"[session-memory] Failed to save session memory:",
From f07a58965e1213f3a004f37e536b3efc1f873756 Mon Sep 17 00:00:00 2001
From: Robby
Date: Thu, 22 Jan 2026 20:31:06 +0000
Subject: [PATCH 16/86] fix: only show model switch success when persist
succeeds (fixes #1435)
Previously, the /model command would display 'Model set to X' even when
the session state wasn't actually persisted (when sessionEntry, sessionStore,
or sessionKey were missing). This caused confusion as users saw success
messages but the model didn't actually change.
This fix:
- Tracks whether the model override was actually persisted
- Only shows success message when persist happened
- Shows a clear error message when persist fails
AI-assisted: Claude Opus 4.5 via Clawdbot
Testing: lightly tested (code review, no runtime test)
---
src/auto-reply/reply/directive-handling.impl.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts
index 7f056e6cd..63b1baa8c 100644
--- a/src/auto-reply/reply/directive-handling.impl.ts
+++ b/src/auto-reply/reply/directive-handling.impl.ts
@@ -288,6 +288,7 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
+ let didPersistModel = false;
if (sessionEntry && sessionStore && sessionKey) {
const prevElevatedLevel =
currentElevatedLevel ??
@@ -346,6 +347,7 @@ export async function handleDirectiveOnly(params: {
selection: modelSelection,
profileOverride,
});
+ didPersistModel = true;
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
@@ -447,7 +449,7 @@ export async function handleDirectiveOnly(params: {
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
- if (modelSelection) {
+ if (modelSelection && didPersistModel) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push(
@@ -458,6 +460,8 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
+ } else if (modelSelection && !didPersistModel) {
+ parts.push(`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`);
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
From 784ea4f7d504209d44539e9d40330c845c9120df Mon Sep 17 00:00:00 2001
From: Robby
Date: Thu, 22 Jan 2026 20:33:44 +0000
Subject: [PATCH 17/86] test: add unit tests for model switch persist behavior
Tests verify:
- Success message shown when session state available
- Error message shown when sessionEntry missing
- Error message shown when sessionStore missing
- No model message when no /model directive
Covers edge cases for #1435 fix.
---
...ective-handling.impl.model-persist.test.ts | 174 ++++++++++++++++++
.../reply/directive-handling.impl.ts | 4 +-
2 files changed, 177 insertions(+), 1 deletion(-)
create mode 100644 src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
new file mode 100644
index 000000000..8ac0f12fb
--- /dev/null
+++ b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
@@ -0,0 +1,174 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { ModelAliasIndex } from "../../agents/model-selection.js";
+import type { ClawdbotConfig } from "../../config/config.js";
+import type { SessionEntry } from "../../config/sessions.js";
+import { parseInlineDirectives } from "./directive-handling.js";
+import { handleDirectiveOnly } from "./directive-handling.impl.js";
+
+// Mock dependencies
+vi.mock("../../agents/agent-scope.js", () => ({
+ resolveAgentConfig: vi.fn(() => ({})),
+ resolveAgentDir: vi.fn(() => "/tmp/agent"),
+ resolveSessionAgentId: vi.fn(() => "main"),
+}));
+
+vi.mock("../../agents/sandbox.js", () => ({
+ resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
+}));
+
+vi.mock("../../config/sessions.js", () => ({
+ updateSessionStore: vi.fn(async () => {}),
+}));
+
+vi.mock("../../infra/system-events.js", () => ({
+ enqueueSystemEvent: vi.fn(),
+}));
+
+function baseAliasIndex(): ModelAliasIndex {
+ return { byAlias: new Map(), byKey: new Map() };
+}
+
+function baseConfig(): ClawdbotConfig {
+ return {
+ commands: { text: true },
+ agents: { defaults: {} },
+ } as unknown as ClawdbotConfig;
+}
+
+describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
+ const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
+ const allowedModelCatalog = [
+ { provider: "anthropic", id: "claude-opus-4-5" },
+ { provider: "openai", id: "gpt-4o" },
+ ];
+
+ it("shows success message when session state is available", async () => {
+ const directives = parseInlineDirectives("/model openai/gpt-4o");
+ const sessionEntry: SessionEntry = {
+ sessionId: "s1",
+ updatedAt: Date.now(),
+ };
+ const sessionStore = { "agent:main:dm:1": sessionEntry };
+
+ const result = await handleDirectiveOnly({
+ cfg: baseConfig(),
+ directives,
+ sessionEntry,
+ sessionStore,
+ sessionKey: "agent:main:dm:1",
+ storePath: "/tmp/sessions.json",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-5",
+ aliasIndex: baseAliasIndex(),
+ allowedModelKeys,
+ allowedModelCatalog,
+ resetModelOverride: false,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ initialModelLabel: "anthropic/claude-opus-4-5",
+ formatModelSwitchEvent: (label) => `Switched to ${label}`,
+ });
+
+ expect(result?.text).toContain("Model set to");
+ expect(result?.text).toContain("openai/gpt-4o");
+ expect(result?.text).not.toContain("failed");
+ });
+
+ it("shows error message when sessionEntry is missing", async () => {
+ const directives = parseInlineDirectives("/model openai/gpt-4o");
+ const sessionStore = {};
+
+ const result = await handleDirectiveOnly({
+ cfg: baseConfig(),
+ directives,
+ sessionEntry: undefined, // Missing!
+ sessionStore,
+ sessionKey: "agent:main:dm:1",
+ storePath: "/tmp/sessions.json",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-5",
+ aliasIndex: baseAliasIndex(),
+ allowedModelKeys,
+ allowedModelCatalog,
+ resetModelOverride: false,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ initialModelLabel: "anthropic/claude-opus-4-5",
+ formatModelSwitchEvent: (label) => `Switched to ${label}`,
+ });
+
+ expect(result?.text).toContain("failed");
+ expect(result?.text).toContain("session state unavailable");
+ });
+
+ it("shows error message when sessionStore is missing", async () => {
+ const directives = parseInlineDirectives("/model openai/gpt-4o");
+ const sessionEntry: SessionEntry = {
+ sessionId: "s1",
+ updatedAt: Date.now(),
+ };
+
+ const result = await handleDirectiveOnly({
+ cfg: baseConfig(),
+ directives,
+ sessionEntry,
+ sessionStore: undefined, // Missing!
+ sessionKey: "agent:main:dm:1",
+ storePath: "/tmp/sessions.json",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-5",
+ aliasIndex: baseAliasIndex(),
+ allowedModelKeys,
+ allowedModelCatalog,
+ resetModelOverride: false,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ initialModelLabel: "anthropic/claude-opus-4-5",
+ formatModelSwitchEvent: (label) => `Switched to ${label}`,
+ });
+
+ expect(result?.text).toContain("failed");
+ expect(result?.text).toContain("session state unavailable");
+ });
+
+ it("shows no model message when no /model directive", async () => {
+ const directives = parseInlineDirectives("hello world");
+ const sessionEntry: SessionEntry = {
+ sessionId: "s1",
+ updatedAt: Date.now(),
+ };
+ const sessionStore = { "agent:main:dm:1": sessionEntry };
+
+ const result = await handleDirectiveOnly({
+ cfg: baseConfig(),
+ directives,
+ sessionEntry,
+ sessionStore,
+ sessionKey: "agent:main:dm:1",
+ storePath: "/tmp/sessions.json",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-5",
+ aliasIndex: baseAliasIndex(),
+ allowedModelKeys,
+ allowedModelCatalog,
+ resetModelOverride: false,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ initialModelLabel: "anthropic/claude-opus-4-5",
+ formatModelSwitchEvent: (label) => `Switched to ${label}`,
+ });
+
+ // No model directive = no model message
+ expect(result?.text ?? "").not.toContain("Model set to");
+ expect(result?.text ?? "").not.toContain("failed");
+ });
+});
diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts
index 63b1baa8c..a753085dc 100644
--- a/src/auto-reply/reply/directive-handling.impl.ts
+++ b/src/auto-reply/reply/directive-handling.impl.ts
@@ -461,7 +461,9 @@ export async function handleDirectiveOnly(params: {
parts.push(`Auth profile set to ${profileOverride}.`);
}
} else if (modelSelection && !didPersistModel) {
- parts.push(`Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`);
+ parts.push(
+ `Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
+ );
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
From f552820a75344e8563c7698d8793c9821ec946a4 Mon Sep 17 00:00:00 2001
From: Clawd
Date: Thu, 22 Jan 2026 01:52:51 -0800
Subject: [PATCH 18/86] fix(bluebubbles): call stop typing on idle and NO_REPLY
Previously, typing stop was intentionally skipped because the
BlueBubbles Server DELETE endpoint was bugged (called startTyping
instead of stopTyping). Now that the server bug is fixed, we can
properly stop typing indicators.
- onIdle: now calls sendBlueBubblesTyping(false) to stop typing
- finally block: stops typing when no message sent (NO_REPLY case)
---
extensions/bluebubbles/src/monitor.ts | 21 ++++++++++++++++++---
1 file changed, 18 insertions(+), 3 deletions(-)
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index f55383068..ab503882d 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -1713,8 +1713,17 @@ async function processMessage(
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
}
},
- onIdle: () => {
- // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
+ onIdle: async () => {
+ if (!chatGuidForActions) return;
+ if (!baseUrl || !password) return;
+ try {
+ await sendBlueBubblesTyping(chatGuidForActions, false, {
+ cfg: config,
+ accountId: account.accountId,
+ });
+ } catch (err) {
+ logVerbose(core, runtime, `typing stop failed: ${String(err)}`);
+ }
},
onError: (err, info) => {
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
@@ -1754,7 +1763,13 @@ async function processMessage(
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
- // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
+ // Stop typing indicator when no message was sent (e.g., NO_REPLY)
+ sendBlueBubblesTyping(chatGuidForActions, false, {
+ cfg: config,
+ accountId: account.accountId,
+ }).catch((err) => {
+ logVerbose(core, runtime, `typing stop (no reply) failed: ${String(err)}`);
+ });
}
}
}
From 3993c9a3b4b6a75befacbf503c09856d76b87538 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 21:33:19 +0000
Subject: [PATCH 19/86] fix: stop BlueBubbles typing on idle/no-reply (#1439)
(thanks @Nicell)
---
CHANGELOG.md | 5 +
extensions/bluebubbles/package.json | 2 +-
extensions/bluebubbles/src/monitor.test.ts | 94 +++++++++++++++++++
extensions/copilot-proxy/package.json | 2 +-
extensions/diagnostics-otel/package.json | 2 +-
extensions/discord/package.json | 2 +-
.../google-antigravity-auth/package.json | 2 +-
.../google-gemini-cli-auth/package.json | 2 +-
extensions/imessage/package.json | 2 +-
extensions/lobster/package.json | 2 +-
extensions/matrix/CHANGELOG.md | 5 +
extensions/matrix/package.json | 2 +-
extensions/memory-core/package.json | 2 +-
extensions/memory-lancedb/package.json | 2 +-
extensions/msteams/CHANGELOG.md | 5 +
extensions/msteams/package.json | 2 +-
extensions/nextcloud-talk/package.json | 2 +-
extensions/nostr/CHANGELOG.md | 5 +
extensions/nostr/package.json | 2 +-
extensions/signal/package.json | 2 +-
extensions/slack/package.json | 2 +-
extensions/telegram/package.json | 2 +-
extensions/voice-call/CHANGELOG.md | 5 +
extensions/voice-call/package.json | 2 +-
extensions/whatsapp/package.json | 2 +-
extensions/zalo/CHANGELOG.md | 5 +
extensions/zalo/package.json | 2 +-
extensions/zalouser/CHANGELOG.md | 5 +
extensions/zalouser/package.json | 2 +-
package.json | 2 +-
30 files changed, 151 insertions(+), 22 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 489f15890..f6b4e140b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
Docs: https://docs.clawd.bot
+## 2026.1.22
+
+### Fixes
+- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
+
## 2026.1.21-2
### Fixes
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index 0ff638c28..d511722a9 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index ee9b15084..0dcccbef8 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -1563,6 +1563,100 @@ describe("BlueBubbles webhook monitor", () => {
expect.any(Object),
);
});
+
+ it("stops typing on idle", async () => {
+ const { sendBlueBubblesTyping } = await import("./chat.js");
+ vi.mocked(sendBlueBubblesTyping).mockClear();
+
+ const account = createMockAccount();
+ const config: ClawdbotConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const payload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: Date.now(),
+ },
+ };
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
+ await params.dispatcherOptions.onReplyStart?.();
+ await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
+ await params.dispatcherOptions.onIdle?.();
+ });
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
+ const res = createMockResponse();
+
+ await handleBlueBubblesWebhookRequest(req, res);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
+ expect.any(String),
+ false,
+ expect.any(Object),
+ );
+ });
+
+ it("stops typing when no reply is sent", async () => {
+ const { sendBlueBubblesTyping } = await import("./chat.js");
+ vi.mocked(sendBlueBubblesTyping).mockClear();
+
+ const account = createMockAccount();
+ const config: ClawdbotConfig = {};
+ const core = createMockRuntime();
+ setBlueBubblesRuntime(core);
+
+ unregister = registerBlueBubblesWebhookTarget({
+ account,
+ config,
+ runtime: { log: vi.fn(), error: vi.fn() },
+ core,
+ path: "/bluebubbles-webhook",
+ });
+
+ const payload = {
+ type: "new-message",
+ data: {
+ text: "hello",
+ handle: { address: "+15551234567" },
+ isGroup: false,
+ isFromMe: false,
+ guid: "msg-1",
+ chatGuid: "iMessage;-;+15551234567",
+ date: Date.now(),
+ },
+ };
+
+ mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
+
+ const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
+ const res = createMockResponse();
+
+ await handleBlueBubblesWebhookRequest(req, res);
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
+ expect.any(String),
+ false,
+ expect.any(Object),
+ );
+ });
});
describe("outbound message ids", () => {
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index 64d23edb2..36b9e1403 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index a71c880c7..fd1b655a0 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index 7073d8b8e..8f43497a9 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json
index 6f60b9fb1..c7626c272 100644
--- a/extensions/google-antigravity-auth/package.json
+++ b/extensions/google-antigravity-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json
index c94a78f29..9e9515f3e 100644
--- a/extensions/google-gemini-cli-auth/package.json
+++ b/extensions/google-gemini-cli-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json
index 2b0b36972..94b120b26 100644
--- a/extensions/imessage/package.json
+++ b/extensions/imessage/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json
index 3e62dc2f9..2b4a5b2dd 100644
--- a/extensions/lobster/package.json
+++ b/extensions/lobster/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
diff --git a/extensions/matrix/CHANGELOG.md b/extensions/matrix/CHANGELOG.md
index 603b5ed19..2d25c41c4 100644
--- a/extensions/matrix/CHANGELOG.md
+++ b/extensions/matrix/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index 5f70904b0..e5dc66a8c 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index ffbee5ff6..57dfc36ef 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json
index c052ce995..bdaa21f35 100644
--- a/extensions/memory-lancedb/package.json
+++ b/extensions/memory-lancedb/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
diff --git a/extensions/msteams/CHANGELOG.md b/extensions/msteams/CHANGELOG.md
index d6bbd6789..aa132c382 100644
--- a/extensions/msteams/CHANGELOG.md
+++ b/extensions/msteams/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json
index b8a5921af..5f7843f8a 100644
--- a/extensions/msteams/package.json
+++ b/extensions/msteams/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json
index 6286b7ae4..c5ee0b8c6 100644
--- a/extensions/nextcloud-talk/package.json
+++ b/extensions/nextcloud-talk/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md
index 2ab179113..2005c22b3 100644
--- a/extensions/nostr/CHANGELOG.md
+++ b/extensions/nostr/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json
index 2e0bd0132..dc5a9e002 100644
--- a/extensions/nostr/package.json
+++ b/extensions/nostr/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
diff --git a/extensions/signal/package.json b/extensions/signal/package.json
index 3a80b650e..6c26e7774 100644
--- a/extensions/signal/package.json
+++ b/extensions/signal/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
diff --git a/extensions/slack/package.json b/extensions/slack/package.json
index d07ec2fda..93470c49d 100644
--- a/extensions/slack/package.json
+++ b/extensions/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json
index 38333c2f3..2dc95db2e 100644
--- a/extensions/telegram/package.json
+++ b/extensions/telegram/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index 807c6ffb6..08d0f5006 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json
index a56fafdf8..ef0323ee4 100644
--- a/extensions/voice-call/package.json
+++ b/extensions/voice-call/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json
index 44a7cd447..49f0fb541 100644
--- a/extensions/whatsapp/package.json
+++ b/extensions/whatsapp/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md
index 1890023c2..a5e56e1da 100644
--- a/extensions/zalo/CHANGELOG.md
+++ b/extensions/zalo/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json
index cf26a354d..0f59602e7 100644
--- a/extensions/zalo/package.json
+++ b/extensions/zalo/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md
index f0154f95d..f5b7a3071 100644
--- a/extensions/zalouser/CHANGELOG.md
+++ b/extensions/zalouser/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## 2026.1.22
+
+### Changes
+- Version alignment with core Clawdbot release numbers.
+
## 2026.1.21
### Changes
diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json
index 932fe6b3d..71e6da3cb 100644
--- a/extensions/zalouser/package.json
+++ b/extensions/zalouser/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
- "version": "2026.1.21",
+ "version": "2026.1.22",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
diff --git a/package.json b/package.json
index f28018845..4d1a21b71 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
- "version": "2026.1.21-2",
+ "version": "2026.1.22",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
From 1ef2de1276eb7a123c6efd8d60b1ec52f4354b8b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 21:40:54 +0000
Subject: [PATCH 20/86] fix: cover missing session key model switch persist
(#1465) (thanks @robbyczgw-cla)
---
CHANGELOG.md | 1 +
...ective-handling.impl.model-persist.test.ts | 33 +++++++++++++++++++
2 files changed, 34 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6b4e140b..972f692bf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
+- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
## 2026.1.21-2
diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
index 8ac0f12fb..b8a70b4a8 100644
--- a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
+++ b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
@@ -138,6 +138,39 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).toContain("session state unavailable");
});
+ it("shows error message when sessionKey is missing", async () => {
+ const directives = parseInlineDirectives("/model openai/gpt-4o");
+ const sessionEntry: SessionEntry = {
+ sessionId: "s1",
+ updatedAt: Date.now(),
+ };
+ const sessionStore = { "agent:main:dm:1": sessionEntry };
+
+ const result = await handleDirectiveOnly({
+ cfg: baseConfig(),
+ directives,
+ sessionEntry,
+ sessionStore,
+ sessionKey: undefined, // Missing!
+ storePath: "/tmp/sessions.json",
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "anthropic",
+ defaultModel: "claude-opus-4-5",
+ aliasIndex: baseAliasIndex(),
+ allowedModelKeys,
+ allowedModelCatalog,
+ resetModelOverride: false,
+ provider: "anthropic",
+ model: "claude-opus-4-5",
+ initialModelLabel: "anthropic/claude-opus-4-5",
+ formatModelSwitchEvent: (label) => `Switched to ${label}`,
+ });
+
+ expect(result?.text).toContain("failed");
+ expect(result?.text).toContain("session state unavailable");
+ });
+
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = {
From db146837a1dece1a07665b17dae1887761cd7d24 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 21:55:10 +0000
Subject: [PATCH 21/86] fix: move session-memory changelog entry
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 972f692bf..fa1f44d3d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
+- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
## 2026.1.21-2
From 482fcd2f2c8f0b10f0b1753329741eb7d7dbb01c Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 21:57:02 +0000
Subject: [PATCH 22/86] fix: resolve control UI avatar URLs (#1457) (thanks
@dlauer)
---
CHANGELOG.md | 2 +-
src/gateway/control-ui.test.ts | 66 +++++++++++++++++++++++++++++
src/gateway/control-ui.ts | 45 ++++++++++++++++----
src/gateway/server-methods/agent.ts | 18 +++-----
4 files changed, 110 insertions(+), 21 deletions(-)
create mode 100644 src/gateway/control-ui.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa1f44d3d..40306679b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
-- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123.
+- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
## 2026.1.21-2
diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts
new file mode 100644
index 000000000..5149830f3
--- /dev/null
+++ b/src/gateway/control-ui.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveAssistantAvatarUrl } from "./control-ui.js";
+
+describe("resolveAssistantAvatarUrl", () => {
+ it("keeps remote and data URLs", () => {
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "https://example.com/avatar.png",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("https://example.com/avatar.png");
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "data:image/png;base64,abc",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("data:image/png;base64,abc");
+ });
+
+ it("prefixes basePath for /avatar endpoints", () => {
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "/avatar/main",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("/ui/avatar/main");
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "/ui/avatar/main",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("/ui/avatar/main");
+ });
+
+ it("maps local avatar paths to the avatar endpoint", () => {
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "avatars/me.png",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("/ui/avatar/main");
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "avatars/profile",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("/ui/avatar/main");
+ });
+
+ it("keeps short text avatars", () => {
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "PS",
+ agentId: "main",
+ basePath: "/ui",
+ }),
+ ).toBe("PS");
+ });
+});
diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts
index 13ee309de..f51a49111 100644
--- a/src/gateway/control-ui.ts
+++ b/src/gateway/control-ui.ts
@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
-function buildAvatarUrl(basePath: string, agentId: string): string {
+export function buildAvatarUrl(basePath: string, agentId: string): string {
return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
}
@@ -206,22 +206,49 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
-function looksLikeLocalAvatarPath(value: string | undefined): boolean {
- if (!value) return false;
- if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return false;
+function looksLikeLocalAvatarPath(value: string): boolean {
+ if (/[\\/]/.test(value)) return true;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
}
+export function resolveAssistantAvatarUrl(params: {
+ avatar?: string | null;
+ agentId?: string | null;
+ basePath?: string;
+}): string | undefined {
+ const avatar = params.avatar?.trim();
+ if (!avatar) return undefined;
+ if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
+
+ const basePath = normalizeControlUiBasePath(params.basePath);
+ const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
+ if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
+ return `${basePath}${avatar}`;
+ }
+ if (avatar.startsWith(baseAvatarPrefix)) return avatar;
+
+ if (!params.agentId) return avatar;
+ if (looksLikeLocalAvatarPath(avatar)) {
+ return buildAvatarUrl(basePath, params.agentId);
+ }
+ return avatar;
+}
+
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY;
- // Resolve local file avatars to /avatar/{agentId} URL
- let avatarValue = identity.avatar;
- if (looksLikeLocalAvatarPath(avatarValue) && identity.agentId) {
- avatarValue = buildAvatarUrl(basePath, identity.agentId);
- }
+ const resolvedAgentId =
+ typeof (identity as { agentId?: string }).agentId === "string"
+ ? (identity as { agentId?: string }).agentId
+ : agentId;
+ const avatarValue =
+ resolveAssistantAvatarUrl({
+ avatar: identity.avatar,
+ agentId: resolvedAgentId,
+ basePath,
+ }) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts
index e99d0ecdf..636272845 100644
--- a/src/gateway/server-methods/agent.ts
+++ b/src/gateway/server-methods/agent.ts
@@ -38,6 +38,7 @@ import {
import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
+import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -407,17 +408,12 @@ export const agentHandlers: GatewayRequestHandlers = {
}
const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId });
- // Resolve local file avatars to /avatar/{agentId} URL
- let avatarValue = identity.avatar;
- if (
- avatarValue &&
- !/^https?:\/\//i.test(avatarValue) &&
- !/^data:image\//i.test(avatarValue) &&
- /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(avatarValue) &&
- identity.agentId
- ) {
- avatarValue = `/avatar/${identity.agentId}`;
- }
+ const avatarValue =
+ resolveAssistantAvatarUrl({
+ avatar: identity.avatar,
+ agentId: identity.agentId,
+ basePath: cfg.gateway?.controlUi?.basePath,
+ }) ?? identity.avatar;
respond(true, { ...identity, avatar: avatarValue }, undefined);
},
"agent.wait": async ({ params, respond }) => {
From 826013c990d5a4bdcd71a9a31b17f46c24777f08 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 22:02:00 +0000
Subject: [PATCH 23/86] docs: refresh nodes + pairing docs
---
docs/concepts/architecture.md | 5 +++--
docs/concepts/presence.md | 6 +++---
docs/gateway/pairing.md | 4 ++++
docs/nodes/index.md | 28 ++++++++++++++++------------
docs/start/pairing.md | 22 ++++++++++++----------
5 files changed, 38 insertions(+), 27 deletions(-)
diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md
index 722f3b4a8..9e9309b2c 100644
--- a/docs/concepts/architecture.md
+++ b/docs/concepts/architecture.md
@@ -5,7 +5,7 @@ read_when:
---
# Gateway architecture
-Last updated: 2026-01-19
+Last updated: 2026-01-22
## Overview
@@ -34,7 +34,8 @@ Last updated: 2026-01-19
### Nodes (macOS / iOS / Android / headless)
- Connect to the **same WS server** with `role: node`.
-- Pair with the Gateway to receive a token.
+- Provide a device identity in `connect`; pairing is **device‑based** (role `node`) and
+ approval lives in the device pairing store.
- Expose commands like `canvas.*`, `camera.*`, `screen.record`, `location.get`.
Protocol details:
diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md
index a9c6e2003..4a1694657 100644
--- a/docs/concepts/presence.md
+++ b/docs/concepts/presence.md
@@ -52,10 +52,10 @@ Instances list, `client.mode === "cli"` is **not** turned into a presence entry.
Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`.
-### 4) Node bridge beacons
+### 4) Node connects (role: node)
-When a node bridge connection authenticates, the Gateway emits a presence entry
-for that node and refreshes it periodically so it doesn’t expire.
+When a node connects over the Gateway WebSocket with `role: node`, the Gateway
+upserts a presence entry for that node (same flow as other WS clients).
## Merge + dedupe rules (why `instanceId` matters)
diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md
index 8acfc4846..2d9626418 100644
--- a/docs/gateway/pairing.md
+++ b/docs/gateway/pairing.md
@@ -11,6 +11,10 @@ In Gateway-owned pairing, the **Gateway** is the source of truth for which nodes
are allowed to join. UIs (macOS app, future clients) are just frontends that
approve or reject pending requests.
+**Important:** WS nodes use **device pairing** (role `node`) during `connect`.
+`node.pair.*` is a separate pairing store and does **not** gate the WS handshake.
+Only clients that explicitly call `node.pair.*` use this flow.
+
## Concepts
- **Pending request**: a node asked to join; requires approval.
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 6bb48a3e9..80e1015fc 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -8,9 +8,11 @@ read_when:
# Nodes
-A **node** is a companion device (iOS/Android today) that connects to the Gateway over the **Bridge** and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Bridge protocol details: [Bridge protocol](/gateway/bridge-protocol).
+A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
-macOS can also run in **node mode**: the menubar app connects to the Gateway’s bridge and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
+Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only).
+
+macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
Notes:
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
@@ -18,21 +20,23 @@ Notes:
## Pairing + status
-Pairing is gateway-owned and approval-based. See [Gateway pairing](/gateway/pairing) for the full flow.
+**WS nodes use device pairing.** Nodes present a device identity during `connect`; the Gateway
+creates a device pairing request for `role: node`. Approve via the devices CLI (or UI).
Quick CLI:
```bash
-clawdbot nodes pending
-clawdbot nodes approve
-clawdbot nodes reject
+clawdbot devices list
+clawdbot devices approve
+clawdbot devices reject
clawdbot nodes status
clawdbot nodes describe --node
-clawdbot nodes rename --node --name "Kitchen iPad"
```
Notes:
-- `nodes rename` stores a display name override in the gateway pairing store.
+- `nodes status` marks a node as **paired** when its device pairing role includes `node`.
+- `node.pair.*` (CLI: `clawdbot nodes pending/approve/reject`) is a separate gateway-owned
+ node pairing store; it does **not** gate the WS `connect` handshake.
## Remote node host (system.run)
@@ -275,7 +279,7 @@ Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
-bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
+WebSocket and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
@@ -292,9 +296,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
-- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
+- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
## Mac node mode
-- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
-- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
+- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
+- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
diff --git a/docs/start/pairing.md b/docs/start/pairing.md
index 4c811d580..ce5bf23f2 100644
--- a/docs/start/pairing.md
+++ b/docs/start/pairing.md
@@ -45,27 +45,29 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant).
-## 2) Node pairing (iOS/Android nodes joining the gateway)
+## 2) Node device pairing (iOS/Android/macOS/headless nodes)
-Nodes (iOS/Android, future hardware, etc.) connect to the Gateway and request to join.
-The Gateway keeps an authoritative allowlist; new nodes require explicit approve/reject.
+Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
+creates a device pairing request that must be approved.
-### Approve a node
+### Approve a node device
```bash
-clawdbot nodes pending
-clawdbot nodes approve
+clawdbot devices list
+clawdbot devices approve
+clawdbot devices reject
```
### Where the state lives
-Stored under `~/.clawdbot/nodes/`:
+Stored under `~/.clawdbot/devices/`:
- `pending.json` (short-lived; pending requests expire)
-- `paired.json` (paired nodes + tokens)
+- `paired.json` (paired devices + tokens)
-### Details
+### Notes
-Full protocol + design notes: [Gateway pairing](/gateway/pairing)
+- The legacy `node.pair.*` API (CLI: `clawdbot nodes pending/approve`) is a
+ separate gateway-owned pairing store. WS nodes still require device pairing.
## Related docs
From 411ce7e231da527477b1347a1633d88935a59e37 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 22:24:25 +0000
Subject: [PATCH 24/86] fix: surface concrete ai error details
---
CHANGELOG.md | 1 +
...dded-helpers.formatassistanterrortext.test.ts | 4 +---
...-helpers.formatrawassistanterrorforui.test.ts | 6 ++++++
...bedded-helpers.sanitizeuserfacingtext.test.ts | 4 ++--
src/agents/pi-embedded-helpers/errors.ts | 16 ++++++++++++----
src/agents/pi-embedded-runner/run/payloads.ts | 12 ++++++++++++
src/gateway/assistant-identity.test.ts | 8 +++++---
7 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40306679b..73355315d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ Docs: https://docs.clawd.bot
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
+- Agents: surface concrete API error details instead of generic AI service errors.
## 2026.1.21-2
diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
index e0a4c897c..e8c514a32 100644
--- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
+++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
@@ -43,8 +43,6 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError(
'{"type":"error","error":{"message":"Something exploded","type":"server_error"}}',
);
- expect(formatAssistantErrorText(msg)).toBe(
- "The AI service returned an error. Please try again.",
- );
+ expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
});
});
diff --git a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts
index 09e5e443a..9dbadf777 100644
--- a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts
+++ b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts
@@ -17,4 +17,10 @@ describe("formatRawAssistantErrorForUi", () => {
it("renders a generic unknown error message when raw is empty", () => {
expect(formatRawAssistantErrorForUi("")).toContain("unknown error");
});
+
+ it("formats plain HTTP status lines", () => {
+ expect(formatRawAssistantErrorForUi("500 Internal Server Error")).toBe(
+ "HTTP 500: Internal Server Error",
+ );
+ });
});
diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
index df84dd6cd..9fac95b9c 100644
--- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
+++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
@@ -19,12 +19,12 @@ describe("sanitizeUserFacingText", () => {
it("sanitizes HTTP status errors with error hints", () => {
expect(sanitizeUserFacingText("500 Internal Server Error")).toBe(
- "The AI service returned an error. Please try again.",
+ "HTTP 500: Internal Server Error",
);
});
it("sanitizes raw API error payloads", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
- expect(sanitizeUserFacingText(raw)).toBe("The AI service returned an error. Please try again.");
+ expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
});
});
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index d29842958..e8fb7a4d1 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -201,6 +201,14 @@ export function formatRawAssistantErrorForUi(raw?: string): string {
const trimmed = (raw ?? "").trim();
if (!trimmed) return "LLM request failed with an unknown error.";
+ const httpMatch = trimmed.match(HTTP_STATUS_PREFIX_RE);
+ if (httpMatch) {
+ const rest = httpMatch[2].trim();
+ if (!rest.startsWith("{")) {
+ return `HTTP ${httpMatch[1]}: ${rest}`;
+ }
+ }
+
const info = parseApiErrorInfo(trimmed);
if (info?.message) {
const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error";
@@ -261,8 +269,8 @@ export function formatAssistantErrorText(
return "The AI service is temporarily overloaded. Please try again in a moment.";
}
- if (isRawApiErrorPayload(raw)) {
- return "The AI service returned an error. Please try again.";
+ if (isLikelyHttpErrorText(raw) || isRawApiErrorPayload(raw)) {
+ return formatRawAssistantErrorForUi(raw);
}
// Never return raw unhandled errors - log for debugging but return safe message
@@ -293,7 +301,7 @@ export function sanitizeUserFacingText(text: string): string {
}
if (isRawApiErrorPayload(trimmed) || isLikelyHttpErrorText(trimmed)) {
- return "The AI service returned an error. Please try again.";
+ return formatRawAssistantErrorForUi(trimmed);
}
if (ERROR_PREFIX_RE.test(trimmed)) {
@@ -303,7 +311,7 @@ export function sanitizeUserFacingText(text: string): string {
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
- return "The AI service returned an error. Please try again.";
+ return formatRawAssistantErrorForUi(trimmed);
}
return stripped;
diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts
index be7ca41f5..005402775 100644
--- a/src/agents/pi-embedded-runner/run/payloads.ts
+++ b/src/agents/pi-embedded-runner/run/payloads.ts
@@ -6,6 +6,7 @@ import { formatToolAggregate } from "../../../auto-reply/tool-meta.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import {
formatAssistantErrorText,
+ formatRawAssistantErrorForUi,
getApiErrorPayloadFingerprint,
isRawApiErrorPayload,
normalizeTextForComparison,
@@ -64,6 +65,12 @@ export function buildEmbeddedRunPayloads(params: {
const rawErrorFingerprint = rawErrorMessage
? getApiErrorPayloadFingerprint(rawErrorMessage)
: null;
+ const formattedRawErrorMessage = rawErrorMessage
+ ? formatRawAssistantErrorForUi(rawErrorMessage)
+ : null;
+ const normalizedFormattedRawErrorMessage = formattedRawErrorMessage
+ ? normalizeTextForComparison(formattedRawErrorMessage)
+ : null;
const normalizedRawErrorText = rawErrorMessage
? normalizeTextForComparison(rawErrorMessage)
: null;
@@ -116,10 +123,15 @@ export function buildEmbeddedRunPayloads(params: {
if (trimmed === genericErrorText) return true;
}
if (rawErrorMessage && trimmed === rawErrorMessage) return true;
+ if (formattedRawErrorMessage && trimmed === formattedRawErrorMessage) return true;
if (normalizedRawErrorText) {
const normalized = normalizeTextForComparison(trimmed);
if (normalized && normalized === normalizedRawErrorText) return true;
}
+ if (normalizedFormattedRawErrorMessage) {
+ const normalized = normalizeTextForComparison(trimmed);
+ if (normalized && normalized === normalizedFormattedRawErrorMessage) return true;
+ }
if (rawErrorFingerprint) {
const fingerprint = getApiErrorPayloadFingerprint(trimmed);
if (fingerprint && fingerprint === rawErrorFingerprint) return true;
diff --git a/src/gateway/assistant-identity.test.ts b/src/gateway/assistant-identity.test.ts
index 5085708e5..f966c7dc7 100644
--- a/src/gateway/assistant-identity.test.ts
+++ b/src/gateway/assistant-identity.test.ts
@@ -13,7 +13,9 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
- expect(resolveAssistantIdentity({ cfg }).avatar).toBe(DEFAULT_ASSISTANT_IDENTITY.avatar);
+ expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(
+ DEFAULT_ASSISTANT_IDENTITY.avatar,
+ );
});
it("keeps short text avatars", () => {
@@ -25,7 +27,7 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
- expect(resolveAssistantIdentity({ cfg }).avatar).toBe("PS");
+ expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("PS");
});
it("keeps path avatars", () => {
@@ -37,6 +39,6 @@ describe("resolveAssistantIdentity avatar normalization", () => {
},
};
- expect(resolveAssistantIdentity({ cfg }).avatar).toBe("avatars/clawd.png");
+ expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/clawd.png");
});
});
From c0c8ee217f54c921fe5c0110b2bf507bee6b4579 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 22:42:27 +0000
Subject: [PATCH 25/86] fix: clarify session_status model-use guidance
---
src/agents/system-prompt.ts | 2 +-
src/agents/tools/session-status-tool.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts
index b952fed4e..aa13a2748 100644
--- a/src/agents/system-prompt.ts
+++ b/src/agents/system-prompt.ts
@@ -212,7 +212,7 @@ export function buildAgentSystemPrompt(params: {
sessions_send: "Send a message to another session/sub-agent",
sessions_spawn: "Spawn a sub-agent session",
session_status:
- "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); optional per-session model override",
+ "Show a /status-equivalent status card (usage + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
image: "Analyze an image with the configured image model",
};
diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts
index f2cc6e980..715512519 100644
--- a/src/agents/tools/session-status-tool.ts
+++ b/src/agents/tools/session-status-tool.ts
@@ -215,7 +215,7 @@ export function createSessionStatusTool(opts?: {
label: "Session Status",
name: "session_status",
description:
- "Show a /status-equivalent session status card. Optional: set per-session model override (model=default resets overrides). Includes usage + cost when available.",
+ "Show a /status-equivalent session status card (usage + cost when available). Use for model-use questions (📊 session_status). Optional: set per-session model override (model=default resets overrides).",
parameters: SessionStatusToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record;
From da3a141c58618d797126c6981cd58cb6eedc36bb Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 22:11:12 +0000
Subject: [PATCH 26/86] refactor: require session state for directive handling
---
CHANGELOG.md | 2 +-
.../reply/directive-handling.fast-lane.ts | 4 +-
...ective-handling.impl.model-persist.test.ts | 94 --------
.../reply/directive-handling.impl.ts | 202 +++++++++---------
.../reply/get-reply-directives-apply.ts | 4 +-
src/auto-reply/reply/get-reply-directives.ts | 4 +-
6 files changed, 104 insertions(+), 206 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73355315d..1e2517fb3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,7 @@
Docs: https://docs.clawd.bot
-## 2026.1.22
+## 2026.1.22 (unreleased)
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts
index 54f3f0375..74a690f89 100644
--- a/src/auto-reply/reply/directive-handling.fast-lane.ts
+++ b/src/auto-reply/reply/directive-handling.fast-lane.ts
@@ -15,8 +15,8 @@ export async function applyInlineDirectivesFastLane(params: {
cfg: ClawdbotConfig;
agentId?: string;
isGroup: boolean;
- sessionEntry?: SessionEntry;
- sessionStore?: Record;
+ sessionEntry: SessionEntry;
+ sessionStore: Record;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
index b8a70b4a8..847ff7030 100644
--- a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
+++ b/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts
@@ -77,100 +77,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).not.toContain("failed");
});
- it("shows error message when sessionEntry is missing", async () => {
- const directives = parseInlineDirectives("/model openai/gpt-4o");
- const sessionStore = {};
-
- const result = await handleDirectiveOnly({
- cfg: baseConfig(),
- directives,
- sessionEntry: undefined, // Missing!
- sessionStore,
- sessionKey: "agent:main:dm:1",
- storePath: "/tmp/sessions.json",
- elevatedEnabled: false,
- elevatedAllowed: false,
- defaultProvider: "anthropic",
- defaultModel: "claude-opus-4-5",
- aliasIndex: baseAliasIndex(),
- allowedModelKeys,
- allowedModelCatalog,
- resetModelOverride: false,
- provider: "anthropic",
- model: "claude-opus-4-5",
- initialModelLabel: "anthropic/claude-opus-4-5",
- formatModelSwitchEvent: (label) => `Switched to ${label}`,
- });
-
- expect(result?.text).toContain("failed");
- expect(result?.text).toContain("session state unavailable");
- });
-
- it("shows error message when sessionStore is missing", async () => {
- const directives = parseInlineDirectives("/model openai/gpt-4o");
- const sessionEntry: SessionEntry = {
- sessionId: "s1",
- updatedAt: Date.now(),
- };
-
- const result = await handleDirectiveOnly({
- cfg: baseConfig(),
- directives,
- sessionEntry,
- sessionStore: undefined, // Missing!
- sessionKey: "agent:main:dm:1",
- storePath: "/tmp/sessions.json",
- elevatedEnabled: false,
- elevatedAllowed: false,
- defaultProvider: "anthropic",
- defaultModel: "claude-opus-4-5",
- aliasIndex: baseAliasIndex(),
- allowedModelKeys,
- allowedModelCatalog,
- resetModelOverride: false,
- provider: "anthropic",
- model: "claude-opus-4-5",
- initialModelLabel: "anthropic/claude-opus-4-5",
- formatModelSwitchEvent: (label) => `Switched to ${label}`,
- });
-
- expect(result?.text).toContain("failed");
- expect(result?.text).toContain("session state unavailable");
- });
-
- it("shows error message when sessionKey is missing", async () => {
- const directives = parseInlineDirectives("/model openai/gpt-4o");
- const sessionEntry: SessionEntry = {
- sessionId: "s1",
- updatedAt: Date.now(),
- };
- const sessionStore = { "agent:main:dm:1": sessionEntry };
-
- const result = await handleDirectiveOnly({
- cfg: baseConfig(),
- directives,
- sessionEntry,
- sessionStore,
- sessionKey: undefined, // Missing!
- storePath: "/tmp/sessions.json",
- elevatedEnabled: false,
- elevatedAllowed: false,
- defaultProvider: "anthropic",
- defaultModel: "claude-opus-4-5",
- aliasIndex: baseAliasIndex(),
- allowedModelKeys,
- allowedModelCatalog,
- resetModelOverride: false,
- provider: "anthropic",
- model: "claude-opus-4-5",
- initialModelLabel: "anthropic/claude-opus-4-5",
- formatModelSwitchEvent: (label) => `Switched to ${label}`,
- });
-
- expect(result?.text).toContain("failed");
- expect(result?.text).toContain("session state unavailable");
- });
-
it("shows no model message when no /model directive", async () => {
const directives = parseInlineDirectives("hello world");
const sessionEntry: SessionEntry = {
diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts
index a753085dc..0765d20dc 100644
--- a/src/auto-reply/reply/directive-handling.impl.ts
+++ b/src/auto-reply/reply/directive-handling.impl.ts
@@ -62,8 +62,8 @@ function resolveExecDefaults(params: {
export async function handleDirectiveOnly(params: {
cfg: ClawdbotConfig;
directives: InlineDirectives;
- sessionEntry?: SessionEntry;
- sessionStore?: Record;
+ sessionEntry: SessionEntry;
+ sessionStore: Record;
sessionKey: string;
storePath?: string;
elevatedEnabled: boolean;
@@ -288,115 +288,111 @@ export async function handleDirectiveOnly(params: {
nextThinkLevel === "xhigh" &&
!supportsXHighThinking(resolvedProvider, resolvedModel);
- let didPersistModel = false;
- if (sessionEntry && sessionStore && sessionKey) {
- const prevElevatedLevel =
- currentElevatedLevel ??
- (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
- (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
- const prevReasoningLevel =
- currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
- let elevatedChanged =
- directives.hasElevatedDirective &&
- directives.elevatedLevel !== undefined &&
- elevatedEnabled &&
- elevatedAllowed;
- let reasoningChanged =
- directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
- if (directives.hasThinkDirective && directives.thinkLevel) {
- if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
- else sessionEntry.thinkingLevel = directives.thinkLevel;
+ const prevElevatedLevel =
+ currentElevatedLevel ??
+ (sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
+ (elevatedAllowed ? ("on" as ElevatedLevel) : ("off" as ElevatedLevel));
+ const prevReasoningLevel =
+ currentReasoningLevel ?? (sessionEntry.reasoningLevel as ReasoningLevel | undefined) ?? "off";
+ let elevatedChanged =
+ directives.hasElevatedDirective &&
+ directives.elevatedLevel !== undefined &&
+ elevatedEnabled &&
+ elevatedAllowed;
+ let reasoningChanged =
+ directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
+ if (directives.hasThinkDirective && directives.thinkLevel) {
+ if (directives.thinkLevel === "off") delete sessionEntry.thinkingLevel;
+ else sessionEntry.thinkingLevel = directives.thinkLevel;
+ }
+ if (shouldDowngradeXHigh) {
+ sessionEntry.thinkingLevel = "high";
+ }
+ if (directives.hasVerboseDirective && directives.verboseLevel) {
+ applyVerboseOverride(sessionEntry, directives.verboseLevel);
+ }
+ if (directives.hasReasoningDirective && directives.reasoningLevel) {
+ if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
+ else sessionEntry.reasoningLevel = directives.reasoningLevel;
+ reasoningChanged =
+ directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
+ }
+ if (directives.hasElevatedDirective && directives.elevatedLevel) {
+ // Unlike other toggles, elevated defaults can be "on".
+ // Persist "off" explicitly so `/elevated off` actually overrides defaults.
+ sessionEntry.elevatedLevel = directives.elevatedLevel;
+ elevatedChanged =
+ elevatedChanged ||
+ (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
+ }
+ if (directives.hasExecDirective && directives.hasExecOptions) {
+ if (directives.execHost) {
+ sessionEntry.execHost = directives.execHost;
}
- if (shouldDowngradeXHigh) {
- sessionEntry.thinkingLevel = "high";
+ if (directives.execSecurity) {
+ sessionEntry.execSecurity = directives.execSecurity;
}
- if (directives.hasVerboseDirective && directives.verboseLevel) {
- applyVerboseOverride(sessionEntry, directives.verboseLevel);
+ if (directives.execAsk) {
+ sessionEntry.execAsk = directives.execAsk;
}
- if (directives.hasReasoningDirective && directives.reasoningLevel) {
- if (directives.reasoningLevel === "off") delete sessionEntry.reasoningLevel;
- else sessionEntry.reasoningLevel = directives.reasoningLevel;
- reasoningChanged =
- directives.reasoningLevel !== prevReasoningLevel && directives.reasoningLevel !== undefined;
+ if (directives.execNode) {
+ sessionEntry.execNode = directives.execNode;
}
- if (directives.hasElevatedDirective && directives.elevatedLevel) {
- // Unlike other toggles, elevated defaults can be "on".
- // Persist "off" explicitly so `/elevated off` actually overrides defaults.
- sessionEntry.elevatedLevel = directives.elevatedLevel;
- elevatedChanged =
- elevatedChanged ||
- (directives.elevatedLevel !== prevElevatedLevel && directives.elevatedLevel !== undefined);
+ }
+ if (modelSelection) {
+ applyModelOverrideToSessionEntry({
+ entry: sessionEntry,
+ selection: modelSelection,
+ profileOverride,
+ });
+ }
+ if (directives.hasQueueDirective && directives.queueReset) {
+ delete sessionEntry.queueMode;
+ delete sessionEntry.queueDebounceMs;
+ delete sessionEntry.queueCap;
+ delete sessionEntry.queueDrop;
+ } else if (directives.hasQueueDirective) {
+ if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
+ if (typeof directives.debounceMs === "number") {
+ sessionEntry.queueDebounceMs = directives.debounceMs;
}
- if (directives.hasExecDirective && directives.hasExecOptions) {
- if (directives.execHost) {
- sessionEntry.execHost = directives.execHost;
- }
- if (directives.execSecurity) {
- sessionEntry.execSecurity = directives.execSecurity;
- }
- if (directives.execAsk) {
- sessionEntry.execAsk = directives.execAsk;
- }
- if (directives.execNode) {
- sessionEntry.execNode = directives.execNode;
- }
+ if (typeof directives.cap === "number") {
+ sessionEntry.queueCap = directives.cap;
}
- if (modelSelection) {
- applyModelOverrideToSessionEntry({
- entry: sessionEntry,
- selection: modelSelection,
- profileOverride,
- });
- didPersistModel = true;
+ if (directives.dropPolicy) {
+ sessionEntry.queueDrop = directives.dropPolicy;
}
- if (directives.hasQueueDirective && directives.queueReset) {
- delete sessionEntry.queueMode;
- delete sessionEntry.queueDebounceMs;
- delete sessionEntry.queueCap;
- delete sessionEntry.queueDrop;
- } else if (directives.hasQueueDirective) {
- if (directives.queueMode) sessionEntry.queueMode = directives.queueMode;
- if (typeof directives.debounceMs === "number") {
- sessionEntry.queueDebounceMs = directives.debounceMs;
- }
- if (typeof directives.cap === "number") {
- sessionEntry.queueCap = directives.cap;
- }
- if (directives.dropPolicy) {
- sessionEntry.queueDrop = directives.dropPolicy;
- }
- }
- sessionEntry.updatedAt = Date.now();
- sessionStore[sessionKey] = sessionEntry;
- if (storePath) {
- await updateSessionStore(storePath, (store) => {
- store[sessionKey] = sessionEntry;
- });
- }
- if (modelSelection) {
- const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
- if (nextLabel !== initialModelLabel) {
- enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
- sessionKey,
- contextKey: `model:${nextLabel}`,
- });
- }
- }
- if (elevatedChanged) {
- const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
- enqueueSystemEvent(formatElevatedEvent(nextElevated), {
+ }
+ sessionEntry.updatedAt = Date.now();
+ sessionStore[sessionKey] = sessionEntry;
+ if (storePath) {
+ await updateSessionStore(storePath, (store) => {
+ store[sessionKey] = sessionEntry;
+ });
+ }
+ if (modelSelection) {
+ const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
+ if (nextLabel !== initialModelLabel) {
+ enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
sessionKey,
- contextKey: "mode:elevated",
- });
- }
- if (reasoningChanged) {
- const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
- enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
- sessionKey,
- contextKey: "mode:reasoning",
+ contextKey: `model:${nextLabel}`,
});
}
}
+ if (elevatedChanged) {
+ const nextElevated = (sessionEntry.elevatedLevel ?? "off") as ElevatedLevel;
+ enqueueSystemEvent(formatElevatedEvent(nextElevated), {
+ sessionKey,
+ contextKey: "mode:elevated",
+ });
+ }
+ if (reasoningChanged) {
+ const nextReasoning = (sessionEntry.reasoningLevel ?? "off") as ReasoningLevel;
+ enqueueSystemEvent(formatReasoningEvent(nextReasoning), {
+ sessionKey,
+ contextKey: "mode:reasoning",
+ });
+ }
const parts: string[] = [];
if (directives.hasThinkDirective && directives.thinkLevel) {
@@ -449,7 +445,7 @@ export async function handleDirectiveOnly(params: {
`Thinking level set to high (xhigh not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
- if (modelSelection && didPersistModel) {
+ if (modelSelection) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push(
@@ -460,10 +456,6 @@ export async function handleDirectiveOnly(params: {
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
- } else if (modelSelection && !didPersistModel) {
- parts.push(
- `Model switch to ${modelSelection.provider}/${modelSelection.model} failed (session state unavailable).`,
- );
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts
index a03ade77d..8f3ac34f0 100644
--- a/src/auto-reply/reply/get-reply-directives-apply.ts
+++ b/src/auto-reply/reply/get-reply-directives-apply.ts
@@ -39,8 +39,8 @@ export async function applyInlineDirectiveOverrides(params: {
agentId: string;
agentDir: string;
agentCfg: AgentDefaults;
- sessionEntry?: SessionEntry;
- sessionStore?: Record;
+ sessionEntry: SessionEntry;
+ sessionStore: Record;
sessionKey: string;
storePath?: string;
sessionScope: Parameters[0]["sessionScope"];
diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts
index 04782c9eb..232d44e21 100644
--- a/src/auto-reply/reply/get-reply-directives.ts
+++ b/src/auto-reply/reply/get-reply-directives.ts
@@ -89,8 +89,8 @@ export async function resolveReplyDirectives(params: {
workspaceDir: string;
agentCfg: AgentDefaults;
sessionCtx: TemplateContext;
- sessionEntry?: SessionEntry;
- sessionStore?: Record;
+ sessionEntry: SessionEntry;
+ sessionStore: Record;
sessionKey: string;
storePath?: string;
sessionScope: Parameters[0]["sessionScope"];
From 56339a17cc1825e396dd3429dd163aa4bfc0f4e6 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 22:51:59 +0000
Subject: [PATCH 27/86] fix: correct gog auth services example (#1454) (thanks
@zerone0x)
---
CHANGELOG.md | 1 +
skills/gog/SKILL.md | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e2517fb3..18b20f346 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Docs: https://docs.clawd.bot
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
+- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
## 2026.1.21-2
diff --git a/skills/gog/SKILL.md b/skills/gog/SKILL.md
index a8d0b0162..416b86065 100644
--- a/skills/gog/SKILL.md
+++ b/skills/gog/SKILL.md
@@ -11,7 +11,7 @@ Use `gog` for Gmail/Calendar/Drive/Contacts/Sheets/Docs. Requires OAuth setup.
Setup (once)
- `gog auth credentials /path/to/client_secret.json`
-- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,tasks,people,sheets`
+- `gog auth add you@gmail.com --services gmail,calendar,drive,contacts,docs,sheets`
- `gog auth list`
Common commands
From c72194734668982ce274dbd200274ad540235d65 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 07:37:55 +0000
Subject: [PATCH 28/86] feat(macos): add attach-only launchd override
---
CHANGELOG.md | 17 ++++++++
.../Sources/Clawdbot/DebugSettings.swift | 36 ++++++++++++++++
.../Clawdbot/GatewayLaunchAgentManager.swift | 41 ++++++++++++++++---
.../Clawdbot/GatewayProcessManager.swift | 14 +++++++
apps/macos/Sources/Clawdbot/MenuBar.swift | 19 +++++++++
docs/platforms/mac/child-process.md | 7 ++++
6 files changed, 128 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18b20f346..99a0b5e36 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,23 @@ Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased)
+### Changes
+- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
+- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
+- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
+- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
+- Docs: add /model allowlist troubleshooting note. (#1405)
+- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
+- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
+- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
+- Signal: add typing indicators and DM read receipts via signal-cli.
+- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
+- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
+- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
+
+### Breaking
+- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
+
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
diff --git a/apps/macos/Sources/Clawdbot/DebugSettings.swift b/apps/macos/Sources/Clawdbot/DebugSettings.swift
index 542c69112..51886bc5d 100644
--- a/apps/macos/Sources/Clawdbot/DebugSettings.swift
+++ b/apps/macos/Sources/Clawdbot/DebugSettings.swift
@@ -16,6 +16,8 @@ struct DebugSettings: View {
@State private var modelsError: String?
private let gatewayManager = GatewayProcessManager.shared
private let healthStore = HealthStore.shared
+ @State private var launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
+ @State private var launchAgentWriteError: String?
@State private var gatewayRootInput: String = GatewayProcessManager.shared.projectRootPath()
@State private var sessionStorePath: String = SessionLoader.defaultStorePath
@State private var sessionStoreSaveError: String?
@@ -47,6 +49,7 @@ struct DebugSettings: View {
VStack(alignment: .leading, spacing: 14) {
self.header
+ self.launchdSection
self.appInfoSection
self.gatewaySection
self.logsSection
@@ -79,6 +82,39 @@ struct DebugSettings: View {
}
}
+ private var launchdSection: some View {
+ GroupBox("Gateway startup") {
+ VStack(alignment: .leading, spacing: 8) {
+ Toggle("Attach only (skip launchd install)", isOn: self.$launchAgentWriteDisabled)
+ .onChange(of: self.launchAgentWriteDisabled) { _, newValue in
+ self.launchAgentWriteError = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(newValue)
+ if self.launchAgentWriteError != nil {
+ self.launchAgentWriteDisabled = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()
+ return
+ }
+ if newValue {
+ Task {
+ _ = await GatewayLaunchAgentManager.set(
+ enabled: false,
+ bundlePath: Bundle.main.bundlePath,
+ port: GatewayEnvironment.gatewayPort())
+ }
+ }
+ }
+
+ Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ if let launchAgentWriteError {
+ Text(launchAgentWriteError)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+ }
+
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Debug")
diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
index 154932c64..f0896e691 100644
--- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift
@@ -4,11 +4,46 @@ enum GatewayLaunchAgentManager {
private static let logger = Logger(subsystem: "com.clawdbot", category: "gateway.launchd")
private static let disableLaunchAgentMarker = ".clawdbot/disable-launchagent"
+ private static var disableLaunchAgentMarkerURL: URL {
+ FileManager().homeDirectoryForCurrentUser
+ .appendingPathComponent(self.disableLaunchAgentMarker)
+ }
+
private static var plistURL: URL {
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent("Library/LaunchAgents/\(gatewayLaunchdLabel).plist")
}
+ static func isLaunchAgentWriteDisabled() -> Bool {
+ FileManager().fileExists(atPath: self.disableLaunchAgentMarkerURL.path)
+ }
+
+ static func setLaunchAgentWriteDisabled(_ disabled: Bool) -> String? {
+ let marker = self.disableLaunchAgentMarkerURL
+ if disabled {
+ do {
+ try FileManager().createDirectory(
+ at: marker.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ if !FileManager().fileExists(atPath: marker.path) {
+ FileManager().createFile(atPath: marker.path, contents: nil)
+ }
+ } catch {
+ return error.localizedDescription
+ }
+ return nil
+ }
+
+ if FileManager().fileExists(atPath: marker.path) {
+ do {
+ try FileManager().removeItem(at: marker)
+ } catch {
+ return error.localizedDescription
+ }
+ }
+ return nil
+ }
+
static func isLoaded() async -> Bool {
guard let loaded = await self.readDaemonLoaded() else { return false }
return loaded
@@ -66,12 +101,6 @@ enum GatewayLaunchAgentManager {
}
extension GatewayLaunchAgentManager {
- private static func isLaunchAgentWriteDisabled() -> Bool {
- let marker = FileManager().homeDirectoryForCurrentUser
- .appendingPathComponent(self.disableLaunchAgentMarker)
- return FileManager().fileExists(atPath: marker.path)
- }
-
private static func readDaemonLoaded() async -> Bool? {
let result = await self.runDaemonCommandResult(
["status", "--json", "--no-probe"],
diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
index 33156f58f..a4e7d72a7 100644
--- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
@@ -79,6 +79,11 @@ final class GatewayProcessManager {
func ensureLaunchAgentEnabledIfNeeded() async {
guard !CommandResolver.connectionModeIsRemote() else { return }
+ if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
+ self.appendLog("[gateway] launchd auto-enable skipped (attach-only)\n")
+ self.logger.info("gateway launchd auto-enable skipped (disable marker set)")
+ return
+ }
let enabled = await GatewayLaunchAgentManager.isLoaded()
guard !enabled else { return }
let bundlePath = Bundle.main.bundleURL.path
@@ -308,6 +313,15 @@ final class GatewayProcessManager {
return
}
+ if GatewayLaunchAgentManager.isLaunchAgentWriteDisabled() {
+ let message = "Launchd disabled; start the Gateway manually or disable attach-only."
+ self.status = .failed(message)
+ self.lastFailureReason = "launchd disabled"
+ self.appendLog("[gateway] launchd disabled; skipping auto-start\n")
+ self.logger.info("gateway launchd enable skipped (disable marker set)")
+ return
+ }
+
let bundlePath = Bundle.main.bundleURL.path
let port = GatewayEnvironment.gatewayPort()
self.appendLog("[gateway] enabling launchd job (\(gatewayLaunchdLabel)) on port \(port)\n")
diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift
index 9738c310b..26a467e91 100644
--- a/apps/macos/Sources/Clawdbot/MenuBar.swift
+++ b/apps/macos/Sources/Clawdbot/MenuBar.swift
@@ -3,6 +3,7 @@ import Darwin
import Foundation
import MenuBarExtraAccess
import Observation
+import OSLog
import Security
import SwiftUI
@@ -10,6 +11,7 @@ import SwiftUI
struct ClawdbotApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var state: AppState
+ private static let logger = Logger(subsystem: "com.clawdbot", category: "app")
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
@@ -31,6 +33,7 @@ struct ClawdbotApp: App {
init() {
ClawdbotLogging.bootstrapIfNeeded()
+ Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)
}
@@ -91,6 +94,22 @@ struct ClawdbotApp: App {
self.statusItem?.button?.appearsDisabled = paused || sleeping
}
+ private static func applyAttachOnlyOverrideIfNeeded() {
+ let args = CommandLine.arguments
+ guard args.contains("--attach-only") || args.contains("--no-launchd") else { return }
+ if let error = GatewayLaunchAgentManager.setLaunchAgentWriteDisabled(true) {
+ Self.logger.error("attach-only flag failed: \(error, privacy: .public)")
+ return
+ }
+ Task {
+ _ = await GatewayLaunchAgentManager.set(
+ enabled: false,
+ bundlePath: Bundle.main.bundlePath,
+ port: GatewayEnvironment.gatewayPort())
+ }
+ Self.logger.info("attach-only flag enabled")
+ }
+
private var isGatewaySleeping: Bool {
if self.state.isPaused { return false }
switch self.state.connectionMode {
diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md
index b88b0dece..46e98c040 100644
--- a/docs/platforms/mac/child-process.md
+++ b/docs/platforms/mac/child-process.md
@@ -45,6 +45,13 @@ present. To reset manually:
rm ~/.clawdbot/disable-launchagent
```
+## Attach-only mode
+
+To force the macOS app to **never install or manage launchd**, launch it with
+`--attach-only` (or `--no-launchd`). This sets `~/.clawdbot/disable-launchagent`,
+so the app only attaches to an already running Gateway. You can toggle the same
+behavior in Debug Settings.
+
## Remote mode
Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
From 573354f5e44bf952260691cf6a0ad91ef1b3deb1 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 07:41:47 +0000
Subject: [PATCH 29/86] chore(dev): default restart-mac to attach-only
---
scripts/restart-mac.sh | 23 ++++++++++++++++++-----
1 file changed, 18 insertions(+), 5 deletions(-)
diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh
index 8187a9148..8cda970d8 100755
--- a/scripts/restart-mac.sh
+++ b/scripts/restart-mac.sh
@@ -20,6 +20,7 @@ SIGN=0
AUTO_DETECT_SIGNING=1
GATEWAY_WAIT_SECONDS="${CLAWDBOT_GATEWAY_WAIT_SECONDS:-0}"
LAUNCHAGENT_DISABLE_MARKER="${HOME}/.clawdbot/disable-launchagent"
+ATTACH_ONLY=1
log() { printf '%s\n' "$*"; }
fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
@@ -81,11 +82,15 @@ for arg in "$@"; do
--wait|-w) WAIT_FOR_LOCK=1 ;;
--no-sign) NO_SIGN=1; AUTO_DETECT_SIGNING=0 ;;
--sign) SIGN=1; AUTO_DETECT_SIGNING=0 ;;
+ --attach-only) ATTACH_ONLY=1 ;;
+ --no-attach-only) ATTACH_ONLY=0 ;;
--help|-h)
- log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign]"
+ log "Usage: $(basename "$0") [--wait] [--no-sign] [--sign] [--attach-only|--no-attach-only]"
log " --wait Wait for other restart to complete instead of exiting"
log " --no-sign Force no code signing (fastest for development)"
log " --sign Force code signing (will fail if no signing key available)"
+ log " --attach-only Launch app with --attach-only (skip launchd install)"
+ log " --no-attach-only Launch app without attach-only override"
log ""
log "Env:"
log " CLAWDBOT_GATEWAY_WAIT_SECONDS=0 Wait time before gateway port check (unsigned only)"
@@ -115,6 +120,9 @@ log "==> Log: ${LOG_PATH}"
if [[ "$NO_SIGN" -eq 1 ]]; then
log "==> Using --no-sign (unsigned flow enabled)"
fi
+if [[ "$ATTACH_ONLY" -eq 1 ]]; then
+ log "==> Using --attach-only (skip launchd install)"
+fi
acquire_lock
@@ -202,13 +210,13 @@ choose_app_bundle() {
choose_app_bundle
# When signed, clear any previous launchagent override marker.
-if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
+if [[ "$NO_SIGN" -ne 1 && "$ATTACH_ONLY" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
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 ]; then
+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"
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
@@ -231,6 +239,11 @@ if [ "$NO_SIGN" -eq 1 ]; then
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
fi
+ATTACH_ONLY_ARGS=()
+if [[ "$ATTACH_ONLY" -eq 1 ]]; then
+ ATTACH_ONLY_ARGS+=(--args --attach-only)
+fi
+
# 4) Launch the installed app in the foreground so the menu bar extra appears.
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
@@ -241,7 +254,7 @@ run_step "launch app" env -i \
TMPDIR="${TMPDIR:-/tmp}" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
LANG="${LANG:-en_US.UTF-8}" \
- /usr/bin/open "${APP_BUNDLE}"
+ /usr/bin/open "${APP_BUNDLE}" ${ATTACH_ONLY_ARGS[@]:+"${ATTACH_ONLY_ARGS[@]}"}
# 5) Verify the app is alive.
sleep 1.5
@@ -251,6 +264,6 @@ else
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
fi
-if [ "$NO_SIGN" -eq 1 ]; then
+if [ "$NO_SIGN" -eq 1 ] && [ "$ATTACH_ONLY" -ne 1 ]; then
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
fi
From 370896e994905aa581d597a7059197792e418a7a Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 08:08:33 +0000
Subject: [PATCH 30/86] fix(macos): prefer linked channel in health summaries
---
CHANGELOG.md | 1 +
.../Sources/Clawdbot/GatewayProcessManager.swift | 13 ++++++-------
apps/macos/Sources/Clawdbot/HealthStore.swift | 5 +++++
3 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99a0b5e36..dc38707cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -27,6 +27,7 @@ Docs: https://docs.clawd.bot
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: surface concrete API error details instead of generic AI service errors.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
+- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
## 2026.1.21-2
diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
index a4e7d72a7..4dc02593b 100644
--- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift
@@ -242,13 +242,12 @@ final class GatewayProcessManager {
private func describe(details instance: String?, port: Int, snap: HealthSnapshot?) -> String {
let instanceText = instance ?? "pid unknown"
if let snap {
- let linkId = snap.channelOrder?.first(where: {
- if let summary = snap.channels[$0] { return summary.linked != nil }
- return false
- }) ?? snap.channels.keys.first(where: {
- if let summary = snap.channels[$0] { return summary.linked != nil }
- return false
- })
+ let order = snap.channelOrder ?? Array(snap.channels.keys)
+ let linkId = order.first(where: { snap.channels[$0]?.linked == true })
+ ?? order.first(where: { snap.channels[$0]?.linked != nil })
+ guard let linkId else {
+ return "port \(port), health probe succeeded, \(instanceText)"
+ }
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
diff --git a/apps/macos/Sources/Clawdbot/HealthStore.swift b/apps/macos/Sources/Clawdbot/HealthStore.swift
index cc3d5e136..44ebcc053 100644
--- a/apps/macos/Sources/Clawdbot/HealthStore.swift
+++ b/apps/macos/Sources/Clawdbot/HealthStore.swift
@@ -166,6 +166,11 @@ final class HealthStore {
_ snap: HealthSnapshot) -> (id: String, summary: HealthSnapshot.ChannelSummary)?
{
let order = snap.channelOrder ?? Array(snap.channels.keys)
+ for id in order {
+ if let summary = snap.channels[id], summary.linked == true {
+ return (id: id, summary: summary)
+ }
+ }
for id in order {
if let summary = snap.channels[id], summary.linked != nil {
return (id: id, summary: summary)
From 814e9a500e81b40ce504c4477a025f74d055ddba Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:07:40 +0000
Subject: [PATCH 31/86] feat: add manual onboarding flow alias
---
docs/cli/onboard.md | 4 ++
src/cli/program/register.onboard.ts | 4 +-
src/commands/onboard-types.ts | 3 +-
src/commands/onboard.ts | 4 +-
src/wizard/onboarding.gateway-config.ts | 2 +-
src/wizard/onboarding.ts | 89 +++++++++++++------------
6 files changed, 59 insertions(+), 47 deletions(-)
diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md
index 02593a19c..bd100c460 100644
--- a/docs/cli/onboard.md
+++ b/docs/cli/onboard.md
@@ -16,6 +16,10 @@ Related:
```bash
clawdbot onboard
clawdbot onboard --flow quickstart
+clawdbot onboard --flow manual
clawdbot onboard --mode remote --remote-url ws://gateway-host:18789
```
+Flow notes:
+- `quickstart`: minimal prompts, auto-generates a gateway token.
+- `manual`: full prompts for port/bind/auth (alias of `advanced`).
diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts
index f2f75d3cc..281464b6f 100644
--- a/src/cli/program/register.onboard.ts
+++ b/src/cli/program/register.onboard.ts
@@ -48,7 +48,7 @@ export function registerOnboardCommand(program: Command) {
"Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)",
false,
)
- .option("--flow ", "Wizard flow: quickstart|advanced")
+ .option("--flow ", "Wizard flow: quickstart|advanced|manual")
.option("--mode ", "Wizard mode: local|remote")
.option(
"--auth-choice ",
@@ -106,7 +106,7 @@ export function registerOnboardCommand(program: Command) {
workspace: opts.workspace as string | undefined,
nonInteractive: Boolean(opts.nonInteractive),
acceptRisk: Boolean(opts.acceptRisk),
- flow: opts.flow as "quickstart" | "advanced" | undefined,
+ flow: opts.flow as "quickstart" | "advanced" | "manual" | undefined,
mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as AuthChoice | undefined,
tokenProvider: opts.tokenProvider as string | undefined,
diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts
index 2a445053d..fad8fe483 100644
--- a/src/commands/onboard-types.ts
+++ b/src/commands/onboard-types.ts
@@ -42,7 +42,8 @@ export type ProviderChoice = ChannelChoice;
export type OnboardOptions = {
mode?: OnboardMode;
- flow?: "quickstart" | "advanced";
+ /** "manual" is an alias for "advanced". */
+ flow?: "quickstart" | "advanced" | "manual";
workspace?: string;
nonInteractive?: boolean;
/** Required for non-interactive onboarding; skips the interactive risk prompt when true. */
diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts
index 97e7bc3c7..d8618a871 100644
--- a/src/commands/onboard.ts
+++ b/src/commands/onboard.ts
@@ -12,7 +12,9 @@ import type { OnboardOptions } from "./onboard-types.js";
export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) {
assertSupportedRuntime(runtime);
const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
- const normalizedOpts = authChoice === opts.authChoice ? opts : { ...opts, authChoice };
+ const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow;
+ const normalizedOpts =
+ authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow };
if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) {
runtime.error(
diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts
index 4974615eb..e8163cbad 100644
--- a/src/wizard/onboarding.gateway-config.ts
+++ b/src/wizard/onboarding.gateway-config.ts
@@ -191,7 +191,7 @@ export async function configureGatewayForOnboarding(
const tokenInput = await prompter.text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
- initialValue: quickstartGateway.token ?? randomToken(),
+ initialValue: quickstartGateway.token ?? "",
});
gatewayToken = String(tokenInput).trim() || randomToken();
}
diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts
index 0ec2a7fc0..d6be230a2 100644
--- a/src/wizard/onboarding.ts
+++ b/src/wizard/onboarding.ts
@@ -82,10 +82,9 @@ export async function runOnboardingWizard(
const snapshot = await readConfigFileSnapshot();
let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {};
- if (snapshot.exists) {
- const title = snapshot.valid ? "Existing config detected" : "Invalid config";
- await prompter.note(summarizeExistingConfig(baseConfig), title);
- if (!snapshot.valid && snapshot.issues.length > 0) {
+ if (snapshot.exists && !snapshot.valid) {
+ await prompter.note(summarizeExistingConfig(baseConfig), "Invalid config");
+ if (snapshot.issues.length > 0) {
await prompter.note(
[
...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`),
@@ -95,14 +94,51 @@ export async function runOnboardingWizard(
"Config issues",
);
}
+ await prompter.outro(
+ `Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
+ );
+ runtime.exit(1);
+ return;
+ }
- if (!snapshot.valid) {
- await prompter.outro(
- `Config invalid. Run \`${formatCliCommand("clawdbot doctor")}\` to repair it, then re-run onboarding.`,
- );
- runtime.exit(1);
- return;
- }
+ const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
+ const manualHint = "Configure port, network, Tailscale, and auth options.";
+ const explicitFlowRaw = opts.flow?.trim();
+ const normalizedExplicitFlow = explicitFlowRaw === "manual" ? "advanced" : explicitFlowRaw;
+ if (
+ normalizedExplicitFlow &&
+ normalizedExplicitFlow !== "quickstart" &&
+ normalizedExplicitFlow !== "advanced"
+ ) {
+ runtime.error("Invalid --flow (use quickstart, manual, or advanced).");
+ runtime.exit(1);
+ return;
+ }
+ const explicitFlow: WizardFlow | undefined =
+ normalizedExplicitFlow === "quickstart" || normalizedExplicitFlow === "advanced"
+ ? normalizedExplicitFlow
+ : undefined;
+ let flow: WizardFlow =
+ explicitFlow ??
+ ((await prompter.select({
+ message: "Onboarding mode",
+ options: [
+ { value: "quickstart", label: "QuickStart", hint: quickstartHint },
+ { value: "advanced", label: "Manual", hint: manualHint },
+ ],
+ initialValue: "quickstart",
+ })) as "quickstart" | "advanced");
+
+ if (opts.mode === "remote" && flow === "quickstart") {
+ await prompter.note(
+ "QuickStart only supports local gateways. Switching to Manual mode.",
+ "QuickStart",
+ );
+ flow = "advanced";
+ }
+
+ if (snapshot.exists) {
+ await prompter.note(summarizeExistingConfig(baseConfig), "Existing config detected");
const action = (await prompter.select({
message: "Config handling",
@@ -134,37 +170,6 @@ export async function runOnboardingWizard(
}
}
- const quickstartHint = `Configure details later via ${formatCliCommand("clawdbot configure")}.`;
- const advancedHint = "Configure port, network, Tailscale, and auth options.";
- const explicitFlowRaw = opts.flow?.trim();
- if (explicitFlowRaw && explicitFlowRaw !== "quickstart" && explicitFlowRaw !== "advanced") {
- runtime.error("Invalid --flow (use quickstart or advanced).");
- runtime.exit(1);
- return;
- }
- const explicitFlow: WizardFlow | undefined =
- explicitFlowRaw === "quickstart" || explicitFlowRaw === "advanced"
- ? explicitFlowRaw
- : undefined;
- let flow: WizardFlow =
- explicitFlow ??
- ((await prompter.select({
- message: "Onboarding mode",
- options: [
- { value: "quickstart", label: "QuickStart", hint: quickstartHint },
- { value: "advanced", label: "Advanced", hint: advancedHint },
- ],
- initialValue: "quickstart",
- })) as "quickstart" | "advanced");
-
- if (opts.mode === "remote" && flow === "quickstart") {
- await prompter.note(
- "QuickStart only supports local gateways. Switching to Advanced mode.",
- "QuickStart",
- );
- flow = "advanced";
- }
-
const quickstartGateway: QuickstartGatewayDefaults = (() => {
const hasExisting =
typeof baseConfig.gateway?.port === "number" ||
From 7c336588eada3ad38b7f041c7445baef38fddf46 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:07:47 +0000
Subject: [PATCH 32/86] chore: drop tty from install e2e docker
---
scripts/test-install-sh-e2e-docker.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/test-install-sh-e2e-docker.sh b/scripts/test-install-sh-e2e-docker.sh
index d7f87b329..0ad79aaf8 100755
--- a/scripts/test-install-sh-e2e-docker.sh
+++ b/scripts/test-install-sh-e2e-docker.sh
@@ -17,7 +17,7 @@ docker build \
"$ROOT_DIR/scripts/docker/install-sh-e2e"
echo "==> Run E2E installer test"
-docker run --rm -t \
+docker run --rm \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_TAG="${CLAWDBOT_INSTALL_TAG:-latest}" \
-e CLAWDBOT_E2E_MODELS="$CLAWDBOT_E2E_MODELS" \
From 96f1846c2c01787978b7712e918b48aab09e06b9 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:07:58 +0000
Subject: [PATCH 33/86] docs: align node transport with gateway ws
---
docs/cli/gateway.md | 2 +-
docs/cli/index.md | 5 ++--
docs/cli/node.md | 27 ++++++++++--------
docs/cli/nodes.md | 14 ++++++++++
docs/concepts/presence.md | 1 -
docs/concepts/session-tool.md | 2 +-
docs/concepts/session.md | 2 +-
docs/concepts/typebox.md | 2 +-
docs/debugging.md | 2 +-
docs/gateway/bonjour.md | 29 ++++++++++----------
docs/gateway/bridge-protocol.md | 7 +++--
docs/gateway/configuration-examples.md | 2 +-
docs/gateway/configuration.md | 16 +++++++----
docs/gateway/discovery.md | 35 ++++++++++++------------
docs/gateway/index.md | 6 ++--
docs/gateway/multiple-gateways.md | 5 ++--
docs/gateway/pairing.md | 13 ++++-----
docs/gateway/tailscale.md | 5 ++--
docs/index.md | 8 +++---
docs/network.md | 2 +-
docs/nodes/camera.md | 2 +-
docs/nodes/index.md | 23 +++++++++++++---
docs/nodes/voicewake.md | 6 ++--
docs/platforms/hetzner.md | 7 ++---
docs/platforms/mac/canvas.md | 2 +-
docs/platforms/mac/menu-bar.md | 2 +-
docs/platforms/mac/xpc.md | 14 +++++-----
docs/platforms/macos.md | 4 +--
docs/start/faq.md | 38 ++++++++++----------------
docs/tools/exec-approvals.md | 16 +++++------
docs/tools/exec.md | 4 +--
31 files changed, 163 insertions(+), 140 deletions(-)
diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md
index 253130780..017718745 100644
--- a/docs/cli/gateway.md
+++ b/docs/cli/gateway.md
@@ -122,7 +122,7 @@ clawdbot gateway probe --ssh user@gateway-host
Options:
- `--ssh `: `user@host` or `user@host:port` (port defaults to `22`).
- `--ssh-identity `: identity file.
-- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only).
+- `--ssh-auto`: pick the first discovered gateway host as SSH target (LAN/WAB only).
Config (optional, used as defaults):
- `gateway.remote.sshTarget`
diff --git a/docs/cli/index.md b/docs/cli/index.md
index 1166d7508..6625fdf54 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -293,7 +293,7 @@ Options:
- `--reset` (reset config + credentials + sessions + workspace before wizard)
- `--non-interactive`
- `--mode `
-- `--flow `
+- `--flow ` (manual is an alias for advanced)
- `--auth-choice `
- `--token-provider ` (non-interactive; used with `--auth-choice token`)
- `--token ` (non-interactive; used with `--auth-choice token`)
@@ -791,11 +791,10 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
[`clawdbot node`](/cli/node).
Subcommands:
-- `node run --host --port 18790`
+- `node run --host --port 18789`
- `node status`
- `node install [--host ] [--port ] [--tls] [--tls-fingerprint ] [--node-id ] [--display-name ] [--runtime ] [--force]`
- `node uninstall`
-- `node run`
- `node stop`
- `node restart`
diff --git a/docs/cli/node.md b/docs/cli/node.md
index ee9893f87..72f8786b2 100644
--- a/docs/cli/node.md
+++ b/docs/cli/node.md
@@ -7,7 +7,7 @@ read_when:
# `clawdbot node`
-Run a **headless node host** that connects to the Gateway bridge and exposes
+Run a **headless node host** that connects to the Gateway WebSocket and exposes
`system.run` / `system.which` on this machine.
## Why use a node host?
@@ -26,14 +26,14 @@ node host, so you can keep command access scoped and explicit.
## Run (foreground)
```bash
-clawdbot node run --host --port 18790
+clawdbot node run --host --port 18789
```
Options:
-- `--host `: Gateway bridge host (default: `127.0.0.1`)
-- `--port `: Gateway bridge port (default: `18790`)
-- `--tls`: Use TLS for the bridge connection
-- `--tls-fingerprint `: Pin the bridge certificate fingerprint
+- `--host `: Gateway WebSocket host (default: `127.0.0.1`)
+- `--port `: Gateway WebSocket port (default: `18789`)
+- `--tls`: Use TLS for the gateway connection
+- `--tls-fingerprint `: Expected TLS certificate fingerprint (sha256)
- `--node-id `: Override node id (clears pairing token)
- `--display-name `: Override the node display name
@@ -42,14 +42,14 @@ Options:
Install a headless node host as a user service.
```bash
-clawdbot node install --host --port 18790
+clawdbot node install --host --port 18789
```
Options:
-- `--host `: Gateway bridge host (default: `127.0.0.1`)
-- `--port `: Gateway bridge port (default: `18790`)
-- `--tls`: Use TLS for the bridge connection
-- `--tls-fingerprint `: Pin the bridge certificate fingerprint
+- `--host `: Gateway WebSocket host (default: `127.0.0.1`)
+- `--port `: Gateway WebSocket port (default: `18789`)
+- `--tls`: Use TLS for the gateway connection
+- `--tls-fingerprint `: Expected TLS certificate fingerprint (sha256)
- `--node-id `: Override node id (clears pairing token)
- `--display-name `: Override the node display name
- `--runtime `: Service runtime (`node` or `bun`)
@@ -65,6 +65,8 @@ clawdbot node restart
clawdbot node uninstall
```
+Service commands accept `--json` for machine-readable output.
+
## Pairing
The first connection creates a pending node pair request on the Gateway.
@@ -75,7 +77,8 @@ clawdbot nodes pending
clawdbot nodes approve
```
-The node host stores its node id + token in `~/.clawdbot/node.json`.
+The node host stores its node id, token, display name, and gateway connection info in
+`~/.clawdbot/node.json`.
## Exec approvals
diff --git a/docs/cli/nodes.md b/docs/cli/nodes.md
index 4064006ee..11d149a53 100644
--- a/docs/cli/nodes.md
+++ b/docs/cli/nodes.md
@@ -14,6 +14,9 @@ Related:
- Camera: [Camera nodes](/nodes/camera)
- Images: [Image nodes](/nodes/images)
+Common options:
+- `--url`, `--token`, `--timeout`, `--json`
+
## Common commands
```bash
@@ -40,6 +43,11 @@ clawdbot nodes run --raw "git status"
clawdbot nodes run --agent main --node --raw "git status"
```
+Invoke flags:
+- `--params `: JSON object string (default `{}`).
+- `--invoke-timeout `: node invoke timeout (default `15000`).
+- `--idempotency-key `: optional idempotency key.
+
### Exec-style defaults
`nodes run` mirrors the model’s exec behavior (defaults + approvals):
@@ -47,8 +55,14 @@ clawdbot nodes run --agent main --node --raw "git status"
- Reads `tools.exec.*` (plus `agents.list[].tools.exec.*` overrides).
- Uses exec approvals (`exec.approval.request`) before invoking `system.run`.
- `--node` can be omitted when `tools.exec.node` is set.
+- Requires a node that advertises `system.run` (macOS companion app or headless node host).
Flags:
+- `--cwd `: working directory.
+- `--env `: env override (repeatable).
+- `--command-timeout `: command timeout.
+- `--invoke-timeout `: node invoke timeout (default `30000`).
+- `--needs-screen-recording`: require screen recording permission.
- `--raw `: run a shell string (`/bin/sh -lc` or `cmd.exe /c`).
- `--agent `: agent-scoped approvals/allowlists (defaults to configured agent).
- `--ask `, `--security `: overrides.
diff --git a/docs/concepts/presence.md b/docs/concepts/presence.md
index 4a1694657..83e71bf71 100644
--- a/docs/concepts/presence.md
+++ b/docs/concepts/presence.md
@@ -53,7 +53,6 @@ Clients can send richer periodic beacons via the `system-event` method. The mac
app uses this to report host name, IP, and `lastInputSeconds`.
### 4) Node connects (role: node)
-
When a node connects over the Gateway WebSocket with `role: node`, the Gateway
upserts a presence entry for that node (same flow as other WS clients).
diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md
index c60187638..50281357b 100644
--- a/docs/concepts/session-tool.md
+++ b/docs/concepts/session-tool.md
@@ -19,7 +19,7 @@ Goal: small, hard-to-misuse tool set so agents can list sessions, fetch history,
- Group chats use `agent:::group:` or `agent:::channel:` (pass the full key).
- Cron jobs use `cron:`.
- Hooks use `hook:` unless explicitly set.
-- Node bridge uses `node-` unless explicitly set.
+- Node sessions use `node-` unless explicitly set.
`global` and `unknown` are reserved values and are never listed. If `session.scope = "global"`, we alias it to `main` for all tools so callers never see `global`.
diff --git a/docs/concepts/session.md b/docs/concepts/session.md
index 48c85af09..92f846135 100644
--- a/docs/concepts/session.md
+++ b/docs/concepts/session.md
@@ -52,7 +52,7 @@ the workspace is writable. See [Memory](/concepts/memory) and
- Other sources:
- Cron jobs: `cron:`
- Webhooks: `hook:` (unless explicitly set by the hook)
- - Node bridge runs: `node-`
+ - Node runs: `node-`
## Lifecycle
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
diff --git a/docs/concepts/typebox.md b/docs/concepts/typebox.md
index 1aa9e54f2..1350d983b 100644
--- a/docs/concepts/typebox.md
+++ b/docs/concepts/typebox.md
@@ -46,7 +46,7 @@ Common methods + events:
| Messaging | `send`, `poll`, `agent`, `agent.wait` | side-effects need `idempotencyKey` |
| Chat | `chat.history`, `chat.send`, `chat.abort`, `chat.inject` | WebChat uses these |
| Sessions | `sessions.list`, `sessions.patch`, `sessions.delete` | session admin |
-| Nodes | `node.list`, `node.invoke`, `node.pair.*` | bridge + node actions |
+| Nodes | `node.list`, `node.invoke`, `node.pair.*` | Gateway WS + node actions |
| Events | `tick`, `presence`, `agent`, `chat`, `health`, `shutdown` | server push |
Authoritative list lives in `src/gateway/server.ts` (`METHODS`, `EVENTS`).
diff --git a/docs/debugging.md b/docs/debugging.md
index b7f94d276..713b4acab 100644
--- a/docs/debugging.md
+++ b/docs/debugging.md
@@ -70,7 +70,7 @@ What this does:
- `CLAWDBOT_PROFILE=dev`
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
- - `CLAWDBOT_GATEWAY_PORT=19001` (bridge/canvas/browser shift accordingly)
+ - `CLAWDBOT_GATEWAY_PORT=19001` (browser/canvas shift accordingly)
2) **Dev bootstrap** (`gateway --dev`)
- Writes a minimal config if missing (`gateway.mode=local`, bind loopback).
diff --git a/docs/gateway/bonjour.md b/docs/gateway/bonjour.md
index bb2c6de98..4b63eb0d2 100644
--- a/docs/gateway/bonjour.md
+++ b/docs/gateway/bonjour.md
@@ -7,7 +7,7 @@ read_when:
# Bonjour / mDNS discovery
Clawdbot uses Bonjour (mDNS / DNS‑SD) as a **LAN‑only convenience** to discover
-an active Gateway bridge. It is best‑effort and does **not** replace SSH or
+an active Gateway (WebSocket endpoint). It is best‑effort and does **not** replace SSH or
Tailnet-based connectivity.
## Wide‑area Bonjour (Unicast DNS‑SD) over Tailscale
@@ -31,7 +31,7 @@ browse both `local.` and `clawdbot.internal.` automatically.
```json5
{
- bridge: { bind: "tailnet" }, // tailnet-only (recommended)
+ gateway: { bind: "tailnet" }, // tailnet-only (recommended)
discovery: { wideArea: { enabled: true } } // enables clawdbot.internal DNS-SD publishing
}
```
@@ -63,13 +63,13 @@ In the Tailscale admin console:
Once clients accept tailnet DNS, iOS nodes can browse
`_clawdbot-gw._tcp` in `clawdbot.internal.` without multicast.
-### Bridge listener security (recommended)
+### Gateway listener security (recommended)
-The bridge port (default `18790`) is a plain TCP service. By default it binds to
-`0.0.0.0`, which makes it reachable from any interface on the gateway host.
+The Gateway WS port (default `18789`) binds to loopback by default. For LAN/tailnet
+access, bind explicitly and keep auth enabled.
For tailnet‑only setups:
-- Set `bridge.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
+- Set `gateway.bind: "tailnet"` in `~/.clawdbot/clawdbot.json`.
- Restart the Gateway (or restart the macOS menubar app).
## What advertises
@@ -87,11 +87,12 @@ The Gateway advertises small non‑secret hints to make UI flows convenient:
- `role=gateway`
- `displayName=`
- `lanHost=.local`
-- `gatewayPort=` (informational; Gateway WS is usually loopback‑only)
-- `bridgePort=` (only when bridge is enabled)
+- `gatewayPort=` (Gateway WS + HTTP)
+- `gatewayTls=1` (only when TLS is enabled)
+- `gatewayTlsSha256=` (only when TLS is enabled and fingerprint is available)
- `canvasPort=` (only when the canvas host is enabled; default `18793`)
- `sshPort=` (defaults to 22 when not overridden)
-- `transport=bridge`
+- `transport=gateway`
- `cliPath=` (optional; absolute path to a runnable `clawdbot` entrypoint)
- `tailnetDns=` (optional hint when Tailnet is available)
@@ -125,8 +126,8 @@ The Gateway writes a rolling log file (printed on startup as
The iOS node uses `NWBrowser` to discover `_clawdbot-gw._tcp`.
To capture logs:
-- Settings → Bridge → Advanced → **Discovery Debug Logs**
-- Settings → Bridge → Advanced → **Discovery Logs** → reproduce → **Copy**
+- Settings → Gateway → Advanced → **Discovery Debug Logs**
+- Settings → Gateway → Advanced → **Discovery Logs** → reproduce → **Copy**
The log includes browser state transitions and result‑set changes.
@@ -136,7 +137,7 @@ The log includes browser state transitions and result‑set changes.
- **Multicast blocked**: some Wi‑Fi networks disable mDNS.
- **Sleep / interface churn**: macOS may temporarily drop mDNS results; retry.
- **Browse works but resolve fails**: keep machine names simple (avoid emojis or
- punctuation), then restart the Gateway. The bridge instance name derives from
+ punctuation), then restart the Gateway. The service instance name derives from
the host name, so overly complex names can confuse some resolvers.
## Escaped instance names (`\032`)
@@ -150,9 +151,7 @@ sequences (e.g. spaces become `\032`).
## Disabling / configuration
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
-- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener (and the bridge beacon).
-- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port.
-- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as back‑compat overrides.
+- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT.
- `CLAWDBOT_TAILNET_DNS` publishes a MagicDNS hint in TXT.
- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
diff --git a/docs/gateway/bridge-protocol.md b/docs/gateway/bridge-protocol.md
index 47ff09b4d..a86155f87 100644
--- a/docs/gateway/bridge-protocol.md
+++ b/docs/gateway/bridge-protocol.md
@@ -14,6 +14,9 @@ should use the unified Gateway WebSocket protocol instead.
If you are building an operator or node client, use the
[Gateway protocol](/gateway/protocol).
+**Note:** Current Clawdbot builds no longer ship the TCP bridge listener; this document is kept for historical reference.
+Legacy `bridge.*` config keys are no longer part of the config schema.
+
## Why we have both
- **Security boundary**: the bridge exposes a small allowlist instead of the
@@ -28,7 +31,7 @@ If you are building an operator or node client, use the
- TCP, one JSON object per line (JSONL).
- Optional TLS (when `bridge.tls.enabled` is true).
-- Gateway owns the listener (default `18790`).
+- Legacy default listener port was `18790` (current builds do not start a TCP bridge).
When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
`bridgeTlsSha256` so nodes can pin the certificate.
@@ -54,7 +57,7 @@ Gateway → Client:
- `event`: chat updates for subscribed sessions
- `ping` / `pong`: keepalive
-Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
+Legacy allowlist enforcement lived in `src/gateway/server-bridge.ts` (removed).
## Exec lifecycle events
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index 91b40beac..a9bacccbf 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -568,5 +568,5 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
- If you set `dmPolicy: "open"`, the matching `allowFrom` list must include `"*"`.
- Provider IDs differ (phone numbers, user IDs, channel IDs). Use the provider docs to confirm the format.
-- Optional sections to add later: `web`, `browser`, `ui`, `bridge`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
+- Optional sections to add later: `web`, `browser`, `ui`, `discovery`, `canvasHost`, `talk`, `signal`, `imessage`.
- See [Providers](/channels/whatsapp) and [Troubleshooting](/gateway/troubleshooting) for deeper setup notes.
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index a6e190d9e..a1a14298f 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -1690,7 +1690,7 @@ auto-compaction, instructing the model to store durable memories on disk (e.g.
`memory/YYYY-MM-DD.md`). It triggers when the session token estimate crosses a
soft threshold below the compaction limit.
-Defaults:
+Legacy defaults:
- `memoryFlush.enabled`: `true`
- `memoryFlush.softThresholdTokens`: `4000`
- `memoryFlush.prompt` / `memoryFlush.systemPrompt`: built-in defaults with `NO_REPLY`
@@ -2784,7 +2784,7 @@ Hot-applied (no full gateway restart):
Requires full Gateway restart:
- `gateway` (port/bind/auth/control UI/tailscale)
-- `bridge`
+- `bridge` (legacy)
- `discovery`
- `canvasHost`
- `plugins`
@@ -2802,7 +2802,7 @@ Convenience flags (CLI):
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
- `clawdbot --profile …` → uses `~/.clawdbot-` (port via config/env/flags)
-See [Gateway runbook](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).
+See [Gateway runbook](/gateway) for the derived port mapping (gateway/browser/canvas).
See [Multiple gateways](/gateway/multiple-gateways) for browser/CDP port isolation details.
Example:
@@ -2921,7 +2921,7 @@ The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can
Default root: `~/clawd/canvas`
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)
-The server listens on the **bridge bind host** (LAN or Tailnet) so nodes can reach it.
+The server listens on the **gateway bind host** (LAN or Tailnet) so nodes can reach it.
The server:
- serves files under `canvasHost.root`
@@ -2950,9 +2950,13 @@ Disable with:
- config: `canvasHost: { enabled: false }`
- env: `CLAWDBOT_SKIP_CANVAS_HOST=1`
-### `bridge` (node bridge server)
+### `bridge` (legacy TCP bridge, removed)
-The Gateway can expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
+Current builds no longer include the TCP bridge listener; `bridge.*` config keys are ignored.
+Nodes connect over the Gateway WebSocket. This section is kept for historical reference.
+
+Legacy behavior:
+- The Gateway could expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.
Defaults:
- enabled: `true`
diff --git a/docs/gateway/discovery.md b/docs/gateway/discovery.md
index 400107d2f..83f09c170 100644
--- a/docs/gateway/discovery.md
+++ b/docs/gateway/discovery.md
@@ -3,7 +3,7 @@ summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding th
read_when:
- Implementing or changing Bonjour discovery/advertising
- Adjusting remote connection modes (direct vs SSH)
- - Designing bridge + pairing for remote nodes
+ - Designing node discovery + pairing for remote nodes
---
# Discovery & transports
@@ -17,17 +17,18 @@ The design goal is to keep all network discovery/advertising in the **Node Gatew
## Terms
- **Gateway**: a single long-running gateway process that owns state (sessions, pairing, node registry) and runs channels. Most setups use one per host; isolated multi-gateway setups are possible.
-- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
-- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
+- **Gateway WS (control plane)**: the WebSocket endpoint on `127.0.0.1:18789` by default; can be bound to LAN/tailnet via `gateway.bind`.
+- **Direct WS transport**: a LAN/tailnet-facing Gateway WS endpoint (no SSH).
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
+- **Legacy TCP bridge (deprecated/removed)**: older node transport (see [Bridge protocol](/gateway/bridge-protocol)); no longer advertised for discovery.
Protocol details:
- [Gateway protocol](/gateway/protocol)
-- [Bridge protocol](/gateway/bridge-protocol)
+- [Bridge protocol (legacy)](/gateway/bridge-protocol)
## Why we keep both “direct” and SSH
-- **Direct bridge** is the best UX on the same network and within a tailnet:
+- **Direct WS** is the best UX on the same network and within a tailnet:
- auto-discovery on LAN via Bonjour
- pairing tokens + ACLs owned by the gateway
- no shell access required; protocol surface can stay tight and auditable
@@ -43,7 +44,7 @@ Protocol details:
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
Target direction:
-- The **gateway** advertises its bridge via Bonjour.
+- The **gateway** advertises its WS endpoint via Bonjour.
- Clients browse and show a “pick a gateway” list, then store the chosen endpoint.
Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
@@ -56,19 +57,19 @@ Troubleshooting and beacon details: [Bonjour](/gateway/bonjour).
- `role=gateway`
- `lanHost=.local`
- `sshPort=22` (or whatever is advertised)
- - `gatewayPort=18789` (loopback WS port; informational)
- - `bridgePort=18790` (when bridge is enabled)
+ - `gatewayPort=18789` (Gateway WS + HTTP)
+ - `gatewayTls=1` (only when TLS is enabled)
+ - `gatewayTlsSha256=` (only when TLS is enabled and fingerprint is available)
- `canvasPort=18793` (default canvas host port; serves `/__clawdbot__/canvas/`)
- `cliPath=` (optional; absolute path to a runnable `clawdbot` entrypoint or binary)
- `tailnetDns=` (optional hint; auto-detected when Tailscale is available)
Disable/override:
- `CLAWDBOT_DISABLE_BONJOUR=1` disables advertising.
-- `CLAWDBOT_BRIDGE_ENABLED=0` disables the bridge listener.
-- `bridge.bind` / `bridge.port` in `~/.clawdbot/clawdbot.json` control bridge bind/port (preferred).
-- `CLAWDBOT_BRIDGE_HOST` / `CLAWDBOT_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set.
-- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in the bridge beacon (defaults to 22).
-- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon (auto-detected if unset).
+- `gateway.bind` in `~/.clawdbot/clawdbot.json` controls the Gateway bind mode.
+- `CLAWDBOT_SSH_PORT` overrides the SSH port advertised in TXT (defaults to 22).
+- `CLAWDBOT_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS).
+- `CLAWDBOT_CLI_PATH` overrides the advertised CLI path.
### 2) Tailnet (cross-network)
@@ -97,13 +98,13 @@ Recommended client behavior:
The gateway is the source of truth for node/client admission.
- Pairing requests are created/approved/rejected in the gateway (see [Gateway pairing](/gateway/pairing)).
-- The bridge enforces:
+- The gateway enforces:
- auth (token / keypair)
- - scopes/ACLs (bridge is not a raw proxy to every gateway method)
+ - scopes/ACLs (the gateway is not a raw proxy to every method)
- rate limits
## Responsibilities by component
-- **Gateway**: advertises discovery beacons, owns pairing decisions, runs the bridge listener.
+- **Gateway**: advertises discovery beacons, owns pairing decisions, and hosts the WS endpoint.
- **macOS app**: helps you pick a gateway, shows pairing prompts, and uses SSH only as a fallback.
-- **iOS/Android nodes**: browse Bonjour as a convenience and connect via the paired bridge.
+- **iOS/Android nodes**: browse Bonjour as a convenience and connect to the paired Gateway WS.
diff --git a/docs/gateway/index.md b/docs/gateway/index.md
index 50552bcc5..0725210e8 100644
--- a/docs/gateway/index.md
+++ b/docs/gateway/index.md
@@ -82,14 +82,12 @@ Defaults (can be overridden via env/flags/config):
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
-- `bridge.port=19002` (derived: `gateway.port+1`)
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
- `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
Derived ports (rules of thumb):
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
-- `bridge.port = base + 1` (or `CLAWDBOT_BRIDGE_PORT` / config override)
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
@@ -114,7 +112,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
```
## Protocol (operator view)
-- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol](/gateway/bridge-protocol).
+- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol (legacy)](/gateway/bridge-protocol).
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{id,displayName?,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId?}, caps, auth?, locale?, userAgent? } }`.
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
- After handshake:
@@ -130,7 +128,7 @@ CLAWDBOT_CONFIG_PATH=~/.clawdbot/b.json CLAWDBOT_STATE_DIR=~/.clawdbot-b clawdbo
- `system-event` — post a presence/system note (structured).
- `send` — send a message via the active channel(s).
- `agent` — run an agent turn (streams events back on same connection).
-- `node.list` — list paired + currently-connected bridge nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
+- `node.list` — list paired + currently-connected nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
- `node.describe` — describe a node (capabilities + supported `node.invoke` commands; works for paired nodes and for currently-connected unpaired nodes).
- `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`).
- `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`).
diff --git a/docs/gateway/multiple-gateways.md b/docs/gateway/multiple-gateways.md
index b1bbd7d04..b444290f6 100644
--- a/docs/gateway/multiple-gateways.md
+++ b/docs/gateway/multiple-gateways.md
@@ -13,7 +13,7 @@ Most setups should use one Gateway because a single Gateway can handle multiple
- `CLAWDBOT_STATE_DIR` — per-instance sessions, creds, caches
- `agents.defaults.workspace` — per-instance workspace root
- `gateway.port` (or `--port`) — unique per instance
-- Derived ports (bridge/browser/canvas) must not overlap
+- Derived ports (browser/canvas) must not overlap
If these are shared, you will hit config races and port conflicts.
@@ -47,7 +47,7 @@ Run a second Gateway on the same host with its own:
This keeps the rescue bot isolated from the main bot so it can debug or apply config changes if the primary bot is down.
-Port spacing: leave at least 20 ports between base ports so the derived bridge/browser/canvas/CDP ports never collide.
+Port spacing: leave at least 20 ports between base ports so the derived browser/canvas/CDP ports never collide.
### How to install (rescue bot)
@@ -73,7 +73,6 @@ clawdbot --profile rescue gateway install
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
-- `bridge.port = base + 1`
- `browser.controlUrl port = base + 2`
- `canvasHost.port = base + 4`
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
diff --git a/docs/gateway/pairing.md b/docs/gateway/pairing.md
index 2d9626418..5a5e553b3 100644
--- a/docs/gateway/pairing.md
+++ b/docs/gateway/pairing.md
@@ -19,12 +19,12 @@ Only clients that explicitly call `node.pair.*` use this flow.
- **Pending request**: a node asked to join; requires approval.
- **Paired node**: approved node with an issued auth token.
-- **Bridge**: transport endpoint only; it forwards requests but does not decide
- membership.
+- **Transport**: the Gateway WS endpoint forwards requests but does not decide
+ membership. (Legacy TCP bridge support is deprecated/removed.)
## How pairing works
-1. A node connects to the bridge and requests pairing.
+1. A node connects to the Gateway WS and requests pairing.
2. The Gateway stores a **pending request** and emits `node.pair.requested`.
3. You approve or reject the request (CLI or UI).
4. On approval, the Gateway issues a **new token** (tokens are rotated on re‑pair).
@@ -85,9 +85,8 @@ Security notes:
- Tokens are secrets; treat `paired.json` as sensitive.
- Rotating a token requires re-approval (or deleting the node entry).
-## Bridge behavior
+## Transport behavior
-- The bridge is **transport only**; it does not store membership.
+- The transport is **stateless**; it does not store membership.
- If the Gateway is offline or pairing is disabled, nodes cannot pair.
-- If the bridge is running but the Gateway is in remote mode, pairing still
- happens against the remote Gateway’s store.
+- If the Gateway is in remote mode, pairing still happens against the remote Gateway’s store.
diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md
index 10477f90c..b7e657acc 100644
--- a/docs/gateway/tailscale.md
+++ b/docs/gateway/tailscale.md
@@ -94,8 +94,9 @@ clawdbot gateway --tailscale funnel --auth password
or `tailscale funnel` configuration on shutdown.
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
-- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
- uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
+- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
+ the same Gateway WS endpoint, so Serve can work for node access. (Legacy TCP
+ bridge traffic is not proxied by Serve.)
## Browser control server (remote Gateway + local browser)
diff --git a/docs/index.md b/docs/index.md
index 088522bfa..00a8841fb 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -49,7 +49,7 @@ WhatsApp / Telegram / Discord
▼
┌───────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
- │ (single source) │ tcp://0.0.0.0:18790 (Bridge)
+ │ (single source) │
│ │ http://:18793
│ │ /__clawdbot__/canvas/ (Canvas host)
└───────────┬───────────────┘
@@ -58,8 +58,8 @@ WhatsApp / Telegram / Discord
├─ CLI (clawdbot …)
├─ Chat UI (SwiftUI)
├─ macOS app (Clawdbot.app)
- ├─ iOS node via Bridge + pairing
- └─ Android node via Bridge + pairing
+ ├─ iOS node via Gateway WS + pairing
+ └─ Android node via Gateway WS + pairing
```
Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns channel connections and the WebSocket control plane.
@@ -70,7 +70,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- The wizard now generates a gateway token by default (even for loopback).
- For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
-- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
+- **Nodes**: connect to the Gateway WebSocket (LAN/tailnet/SSH as needed); legacy TCP bridge is deprecated/removed.
- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [Gateway configuration](/gateway/configuration) (`canvasHost`).
- **Remote use**: SSH tunnel or tailnet/VPN; see [Remote access](/gateway/remote) and [Discovery](/gateway/discovery).
diff --git a/docs/network.md b/docs/network.md
index 94e43e85d..155727667 100644
--- a/docs/network.md
+++ b/docs/network.md
@@ -36,7 +36,7 @@ Local trust:
- [Remote access (SSH)](/gateway/remote)
- [Tailscale](/gateway/tailscale)
-## Nodes + bridge
+## Nodes + transports
- [Nodes overview](/nodes)
- [Bridge protocol (legacy nodes)](/gateway/bridge-protocol)
diff --git a/docs/nodes/camera.md b/docs/nodes/camera.md
index 37d61f0fa..6b0f408d3 100644
--- a/docs/nodes/camera.md
+++ b/docs/nodes/camera.md
@@ -138,7 +138,7 @@ Notes:
## Safety + practical limits
- Camera and microphone access trigger the usual OS permission prompts (and require usage strings in Info.plist).
-- Video clips are capped (currently `<= 60s`) to avoid oversized bridge payloads (base64 overhead + message limits).
+- Video clips are capped (currently `<= 60s`) to avoid oversized node payloads (base64 overhead + message limits).
## macOS screen video (OS-level)
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 80e1015fc..0f3055567 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -10,7 +10,7 @@ read_when:
A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
-Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; for older node clients only).
+Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; deprecated/removed for current nodes).
macOS can also run in **node mode**: the menubar app connects to the Gateway’s WS server and exposes its local canvas/camera commands as a node (so `clawdbot nodes …` works against this Mac).
@@ -61,7 +61,7 @@ clawdbot node run --host --port 18789 --display-name "Build Node"
```bash
clawdbot node install --host --port 18789 --display-name "Build Node"
-clawdbot node start
+clawdbot node restart
```
### Pair + name
@@ -243,6 +243,7 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority ` and `--delivery `.
+- macOS nodes drop `PATH` overrides; headless node hosts only accept `PATH` when it prepends the node host PATH.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
@@ -285,20 +286,34 @@ or for running a minimal node alongside a server.
Start it:
```bash
-clawdbot node run --host --port 18790
+clawdbot node run --host --port 18789
```
Notes:
- Pairing is still required (the Gateway will show a node approval prompt).
-- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
+- The node host stores its node id, token, display name, and gateway connection info in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
+<<<<<<< HEAD
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
+||||||| parent of 2ab821adc (docs: align node transport with gateway ws)
+- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
+=======
+- Add `--tls` / `--tls-fingerprint` when the gateway requires TLS.
+>>>>>>> 2ab821adc (docs: align node transport with gateway ws)
## Mac node mode
+<<<<<<< HEAD
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
+||||||| parent of 2ab821adc (docs: align node transport with gateway ws)
+- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
+- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
+=======
+- The macOS menubar app connects to the Gateway WebSocket as a node (so `clawdbot nodes …` works against this Mac).
+- In remote mode, the app opens an SSH tunnel for the gateway port and connects to `localhost`.
+>>>>>>> 2ab821adc (docs: align node transport with gateway ws)
diff --git a/docs/nodes/voicewake.md b/docs/nodes/voicewake.md
index 49a19b111..224e3b8b9 100644
--- a/docs/nodes/voicewake.md
+++ b/docs/nodes/voicewake.md
@@ -41,7 +41,7 @@ Notes:
Who receives it:
- All WebSocket clients (macOS app, WebChat, etc.)
-- All connected bridge nodes (iOS/Android), and also on node connect as an initial “current state” push.
+- All connected nodes (iOS/Android), and also on node connect as an initial “current state” push.
## Client behavior
@@ -53,9 +53,9 @@ Who receives it:
### iOS node
- Uses the global list for `VoiceWakeManager` trigger detection.
-- Editing Wake Words in Settings calls `voicewake.set` (over the bridge) and also keeps local wake-word detection responsive.
+- Editing Wake Words in Settings calls `voicewake.set` (over the Gateway WS) and also keeps local wake-word detection responsive.
### Android node
- Exposes a Wake Words editor in Settings.
-- Calls `voicewake.set` over the bridge so edits sync everywhere.
+- Calls `voicewake.set` over the Gateway WS so edits sync everywhere.
diff --git a/docs/platforms/hetzner.md b/docs/platforms/hetzner.md
index 8f9de7bc0..1a3c2f836 100644
--- a/docs/platforms/hetzner.md
+++ b/docs/platforms/hetzner.md
@@ -129,7 +129,6 @@ CLAWDBOT_IMAGE=clawdbot:latest
CLAWDBOT_GATEWAY_TOKEN=change-me-now
CLAWDBOT_GATEWAY_BIND=lan
CLAWDBOT_GATEWAY_PORT=18789
-CLAWDBOT_BRIDGE_PORT=18790
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
CLAWDBOT_WORKSPACE_DIR=/root/clawd
@@ -166,7 +165,6 @@ services:
- TERM=xterm-256color
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
- - CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
@@ -179,9 +177,8 @@ services:
# To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
- "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
- # Optional: only if you run iOS/Android nodes against this VPS.
- # If you expose these publicly, read /gateway/security and firewall accordingly.
- # - "${CLAWDBOT_BRIDGE_PORT}:18790"
+ # Optional: only if you run iOS/Android nodes against this VPS and need Canvas host.
+ # If you expose this publicly, read /gateway/security and firewall accordingly.
# - "18793:18793"
command:
[
diff --git a/docs/platforms/mac/canvas.md b/docs/platforms/mac/canvas.md
index f706292b8..0e9d52b64 100644
--- a/docs/platforms/mac/canvas.md
+++ b/docs/platforms/mac/canvas.md
@@ -40,7 +40,7 @@ node commands return `CANVAS_DISABLED`.
## Agent API surface
-Canvas is exposed via the **node bridge**, so the agent can:
+Canvas is exposed via the **Gateway WebSocket**, so the agent can:
- show/hide the panel
- navigate to a path or URL
diff --git a/docs/platforms/mac/menu-bar.md b/docs/platforms/mac/menu-bar.md
index 351d2819c..36b39f535 100644
--- a/docs/platforms/mac/menu-bar.md
+++ b/docs/platforms/mac/menu-bar.md
@@ -8,7 +8,7 @@ read_when:
## What is shown
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
- Health status is hidden while work is active; it returns when all sessions are idle.
-- The “Nodes” block in the menu lists **devices** only (gateway bridge nodes via `node.list`), not client/presence entries.
+- The “Nodes” block in the menu lists **devices** only (paired nodes via `node.list`), not client/presence entries.
- A “Usage” section appears under Context when provider usage snapshots are available.
## State model
diff --git a/docs/platforms/mac/xpc.md b/docs/platforms/mac/xpc.md
index 4beaf6fa4..fa3ca208b 100644
--- a/docs/platforms/mac/xpc.md
+++ b/docs/platforms/mac/xpc.md
@@ -1,5 +1,5 @@
---
-summary: "macOS IPC architecture for Clawdbot app, gateway node bridge, and PeekabooBridge"
+summary: "macOS IPC architecture for Clawdbot app, gateway node transport, and PeekabooBridge"
read_when:
- Editing IPC contracts or menu bar app IPC
---
@@ -13,21 +13,21 @@ read_when:
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
## How it works
-### Gateway + node bridge
+### Gateway + node transport
- The app runs the Gateway (local mode) and connects to it as a node.
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
### Node service + app IPC
-- A headless node host service connects to the Gateway bridge.
+- A headless node host service connects to the Gateway WebSocket.
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
- The app performs the exec in UI context, prompts if needed, and returns output.
Diagram (SCI):
```
-Agent -> Gateway -> Bridge -> Node Service (TS)
- | IPC (UDS + token + HMAC + TTL)
- v
- Mac App (UI + TCC + system.run)
+Agent -> Gateway -> Node Service (WS)
+ | IPC (UDS + token + HMAC + TTL)
+ v
+ Mac App (UI + TCC + system.run)
```
### PeekabooBridge (UI automation)
diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md
index b1540def8..e27e10e56 100644
--- a/docs/platforms/macos.md
+++ b/docs/platforms/macos.md
@@ -62,7 +62,7 @@ Node service + app IPC:
Diagram (SCI):
```
-Gateway -> Bridge -> Node Service (TS)
+Gateway -> Node Service (WS)
| IPC (UDS + token + HMAC + TTL)
v
Mac App (UI + TCC + system.run)
@@ -99,7 +99,7 @@ Example:
```
Notes:
-- `allowlist` entries are JSON-encoded argv arrays.
+- `allowlist` entries are glob patterns for resolved binary paths.
- Choosing “Always Allow” in the prompt adds that command to the allowlist.
- `system.run` environment overrides are filtered (drops `PATH`, `DYLD_*`, `LD_*`, `NODE_OPTIONS`, `PYTHON*`, `PERL*`, `RUBYOPT`) and then merged with the app’s environment.
diff --git a/docs/start/faq.md b/docs/start/faq.md
index 286662cb6..d12859020 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -719,11 +719,11 @@ See the full config examples in [Browser](/tools/browser#use-brave-or-another-ch
### How do commands propagate between Telegram, the gateway, and nodes?
Telegram messages are handled by the **gateway**. The gateway runs the agent and
-only then calls nodes over the **Bridge** when a node tool is needed:
+only then calls nodes over the **Gateway WebSocket** when a node tool is needed:
Telegram → Gateway → Agent → `node.*` → Node → Gateway → Telegram
-Nodes don’t see inbound provider traffic; they only receive bridge RPC calls.
+Nodes don’t see inbound provider traffic; they only receive node RPC calls.
### How can my agent access my computer if the Gateway is hosted remotely?
@@ -733,29 +733,28 @@ call `node.*` tools (screen, camera, system) on your local machine over the Brid
Typical setup:
1) Run the Gateway on the always‑on host (VPS/home server).
2) Put the Gateway host + your computer on the same tailnet.
-3) Enable the bridge on the Gateway host:
- ```json5
- { bridge: { enabled: true, bind: "auto" } }
- ```
-4) Open the macOS app locally and connect in **Remote over SSH** mode so it can tunnel
- the bridge port and register as a node.
+3) Ensure the Gateway WS is reachable (tailnet bind or SSH tunnel).
+4) Open the macOS app locally and connect in **Remote over SSH** mode (or direct tailnet)
+ so it can register as a node.
5) Approve the node on the Gateway:
```bash
clawdbot nodes pending
clawdbot nodes approve
```
+No separate TCP bridge is required; nodes connect over the Gateway WebSocket.
+
Security reminder: pairing a macOS node allows `system.run` on that machine. Only
pair devices you trust, and review [Security](/gateway/security).
-Docs: [Nodes](/nodes), [Bridge protocol](/gateway/bridge-protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
+Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
### Do nodes run a gateway service?
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
-A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
+A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
### Is there an API / RPC way to apply config?
@@ -797,26 +796,19 @@ This keeps the gateway bound to loopback and exposes HTTPS via Tailscale. See [T
### How do I connect a Mac node to a remote Gateway (Tailscale Serve)?
-Serve only exposes the **Gateway Control UI**. Nodes use the **bridge port**.
+Serve exposes the **Gateway Control UI + WS**. Nodes connect over the same Gateway WS endpoint.
Recommended setup:
-1) **Enable the bridge on the gateway host**:
- ```json5
- {
- bridge: { enabled: true, bind: "auto" }
- }
- ```
- `auto` prefers a tailnet IP when Tailscale is present.
-2) **Make sure the VPS + Mac are on the same tailnet**.
-3) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
- The app will tunnel the bridge port and connect as a node.
-4) **Approve the node** on the gateway:
+1) **Make sure the VPS + Mac are on the same tailnet**.
+2) **Use the macOS app in Remote mode** (SSH target can be the tailnet hostname).
+ The app will tunnel the Gateway port and connect as a node.
+3) **Approve the node** on the gateway:
```bash
clawdbot nodes pending
clawdbot nodes approve
```
-Docs: [Bridge protocol](/gateway/bridge-protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
+Docs: [Gateway protocol](/gateway/protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
## Env vars and .env loading
diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index 59ac7d119..6857970f6 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -22,7 +22,7 @@ Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node host)
-Planned macOS split:
+macOS split:
- **node host service** forwards `system.run` to the **macOS app** over local IPC.
- **macOS app** enforces approvals + executes the command in UI context.
@@ -103,8 +103,8 @@ Each allowlist entry tracks:
## Auto-allow skill CLIs
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
-are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
-gateway for the skill bin list. Disable this if you want strict manual allowlists.
+are treated as allowlisted on nodes (macOS node or headless node host). This uses
+`skills.bins` over the Gateway RPC to fetch the skill bin list. Disable this if you want strict manual allowlists.
## Safe bins (stdin-only)
@@ -151,12 +151,12 @@ Actions:
- **Always allow** → add to allowlist + run
- **Deny** → block
-### macOS IPC flow (planned)
+### macOS IPC flow
```
-Gateway -> Bridge -> Node Service (TS)
- | IPC (UDS + token + HMAC + TTL)
- v
- Mac App (UI + approvals + system.run)
+Gateway -> Node Service (WS)
+ | IPC (UDS + token + HMAC + TTL)
+ v
+ Mac App (UI + approvals + system.run)
```
Security notes:
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index b5c53593c..068be74ee 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -66,8 +66,8 @@ Example:
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
- if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
- the node host PATH (no replacement).
+ if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
+ the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
Per-agent node binding (use the agent list index in config):
From c7e0dc10fc72a49e7e9e4eba68c04e747debff1b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:12:38 +0000
Subject: [PATCH 34/86] docs: fix remaining node ws references
---
docs/gateway/remote.md | 6 +++---
docs/gateway/tailscale.md | 3 +--
docs/nodes/index.md | 14 --------------
docs/start/faq.md | 2 +-
4 files changed, 5 insertions(+), 20 deletions(-)
diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md
index 800d9761e..aa3fff7f7 100644
--- a/docs/gateway/remote.md
+++ b/docs/gateway/remote.md
@@ -8,7 +8,7 @@ read_when:
This repo supports “remote over SSH” by keeping a single Gateway (the master) running on a dedicated host (desktop/server) and connecting clients to it.
- For **operators (you / the macOS app)**: SSH tunneling is the universal fallback.
-- For **nodes (iOS/Android and future devices)**: prefer the Gateway **Bridge** when on the same LAN/tailnet (see [Discovery](/gateway/discovery)).
+- For **nodes (iOS/Android and future devices)**: connect to the Gateway **WebSocket** (LAN/tailnet or SSH tunnel as needed).
## The core idea
@@ -55,12 +55,12 @@ One gateway service owns state + channels. Nodes are peripherals.
Flow example (Telegram → node):
- Telegram message arrives at the **Gateway**.
- Gateway runs the **agent** and decides whether to call a node tool.
-- Gateway calls the **node** over the Bridge (`node.*` RPC).
+- Gateway calls the **node** over the Gateway WebSocket (`node.*` RPC).
- Node returns the result; Gateway replies back out to Telegram.
Notes:
- **Nodes do not run the gateway service.** Only one gateway should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)).
-- macOS app “node mode” is just a node client over the Bridge.
+- macOS app “node mode” is just a node client over the Gateway WebSocket.
## SSH tunnel (CLI + tools)
diff --git a/docs/gateway/tailscale.md b/docs/gateway/tailscale.md
index b7e657acc..b57ffcc33 100644
--- a/docs/gateway/tailscale.md
+++ b/docs/gateway/tailscale.md
@@ -95,8 +95,7 @@ clawdbot gateway --tailscale funnel --auth password
- `gateway.bind: "tailnet"` is a direct Tailnet bind (no HTTPS, no Serve/Funnel).
- `gateway.bind: "auto"` prefers loopback; use `tailnet` if you want Tailnet-only.
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
- the same Gateway WS endpoint, so Serve can work for node access. (Legacy TCP
- bridge traffic is not proxied by Serve.)
+ the same Gateway WS endpoint, so Serve can work for node access.
## Browser control server (remote Gateway + local browser)
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 0f3055567..3608ae382 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -297,23 +297,9 @@ Notes:
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
-<<<<<<< HEAD
- Add `--tls` / `--tls-fingerprint` when the Gateway WS uses TLS.
-||||||| parent of 2ab821adc (docs: align node transport with gateway ws)
-- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
-=======
-- Add `--tls` / `--tls-fingerprint` when the gateway requires TLS.
->>>>>>> 2ab821adc (docs: align node transport with gateway ws)
## Mac node mode
-<<<<<<< HEAD
- The macOS menubar app connects to the Gateway WS server as a node (so `clawdbot nodes …` works against this Mac).
- In remote mode, the app opens an SSH tunnel for the Gateway port and connects to `localhost`.
-||||||| parent of 2ab821adc (docs: align node transport with gateway ws)
-- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
-- In remote mode, the app opens an SSH tunnel for the bridge port and connects to `localhost`.
-=======
-- The macOS menubar app connects to the Gateway WebSocket as a node (so `clawdbot nodes …` works against this Mac).
-- In remote mode, the app opens an SSH tunnel for the gateway port and connects to `localhost`.
->>>>>>> 2ab821adc (docs: align node transport with gateway ws)
diff --git a/docs/start/faq.md b/docs/start/faq.md
index d12859020..5fa24b5b8 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -728,7 +728,7 @@ Nodes don’t see inbound provider traffic; they only receive node RPC calls.
### How can my agent access my computer if the Gateway is hosted remotely?
Short answer: **pair your computer as a node**. The Gateway runs elsewhere, but it can
-call `node.*` tools (screen, camera, system) on your local machine over the Bridge.
+call `node.*` tools (screen, camera, system) on your local machine over the Gateway WebSocket.
Typical setup:
1) Run the Gateway on the always‑on host (VPS/home server).
From c1e50b718491f0fbc3bda0432de7bd50a3617657 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:19:55 +0000
Subject: [PATCH 35/86] docs: clarify node service commands
---
docs/cli/node.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/cli/node.md b/docs/cli/node.md
index 72f8786b2..8f95a54ea 100644
--- a/docs/cli/node.md
+++ b/docs/cli/node.md
@@ -59,12 +59,13 @@ Manage the service:
```bash
clawdbot node status
-clawdbot node run
clawdbot node stop
clawdbot node restart
clawdbot node uninstall
```
+Use `clawdbot node run` for a foreground node host (no service).
+
Service commands accept `--json` for machine-readable output.
## Pairing
From 5fd699d0bfb032b95fa9caf969c397068210298f Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Thu, 22 Jan 2026 11:35:13 -0800
Subject: [PATCH 36/86] tui: add local shell execution for !-prefixed lines
---
src/tui/tui-input-history.test.ts | 51 ++++++++++++-
src/tui/tui.submit-handler.test.ts | 74 +++++++++++++++++++
src/tui/tui.ts | 115 ++++++++++++++++++++++++++++-
3 files changed, 235 insertions(+), 5 deletions(-)
create mode 100644 src/tui/tui.submit-handler.test.ts
diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts
index 1a6870b99..18333e1fa 100644
--- a/src/tui/tui-input-history.test.ts
+++ b/src/tui/tui-input-history.test.ts
@@ -13,6 +13,7 @@ describe("createEditorSubmitHandler", () => {
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
+ handleBangLine: vi.fn(),
});
handler("hello world");
@@ -21,7 +22,7 @@ describe("createEditorSubmitHandler", () => {
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
});
- it("trims input before adding to history", () => {
+ it("does not trim input before adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
@@ -31,14 +32,15 @@ describe("createEditorSubmitHandler", () => {
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
+ handleBangLine: vi.fn(),
});
handler(" hi ");
- expect(editor.addToHistory).toHaveBeenCalledWith("hi");
+ expect(editor.addToHistory).toHaveBeenCalledWith(" hi ");
});
- it("does not add empty submissions to history", () => {
+ it("does not add empty-string submissions to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
@@ -48,9 +50,10 @@ describe("createEditorSubmitHandler", () => {
editor,
handleCommand: vi.fn(),
sendMessage: vi.fn(),
+ handleBangLine: vi.fn(),
});
- handler(" ");
+ handler("");
expect(editor.addToHistory).not.toHaveBeenCalled();
});
@@ -67,6 +70,7 @@ describe("createEditorSubmitHandler", () => {
editor,
handleCommand,
sendMessage,
+ handleBangLine: vi.fn(),
});
handler("/models");
@@ -88,6 +92,7 @@ describe("createEditorSubmitHandler", () => {
editor,
handleCommand,
sendMessage,
+ handleBangLine: vi.fn(),
});
handler("hello");
@@ -96,4 +101,42 @@ describe("createEditorSubmitHandler", () => {
expect(sendMessage).toHaveBeenCalledWith("hello");
expect(handleCommand).not.toHaveBeenCalled();
});
+
+ it("routes bang-prefixed lines to handleBangLine", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const handleBangLine = vi.fn();
+
+ const handler = createEditorSubmitHandler({
+ editor,
+ handleCommand: vi.fn(),
+ sendMessage: vi.fn(),
+ handleBangLine,
+ });
+
+ handler("!ls");
+
+ expect(handleBangLine).toHaveBeenCalledWith("!ls");
+ });
+
+ it("treats a lone ! as a normal message", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const sendMessage = vi.fn();
+
+ const handler = createEditorSubmitHandler({
+ editor,
+ handleCommand: vi.fn(),
+ sendMessage,
+ handleBangLine: vi.fn(),
+ });
+
+ handler("!");
+
+ expect(sendMessage).toHaveBeenCalledWith("!");
+ });
});
diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts
new file mode 100644
index 000000000..427c1baa4
--- /dev/null
+++ b/src/tui/tui.submit-handler.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createEditorSubmitHandler } from "./tui.js";
+
+describe("createEditorSubmitHandler", () => {
+ it("routes lines starting with ! to handleBangLine", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const handleCommand = vi.fn();
+ const sendMessage = vi.fn();
+ const handleBangLine = vi.fn();
+
+ const onSubmit = createEditorSubmitHandler({
+ editor,
+ handleCommand,
+ sendMessage,
+ handleBangLine,
+ });
+
+ onSubmit("!ls");
+
+ expect(handleBangLine).toHaveBeenCalledTimes(1);
+ expect(handleBangLine).toHaveBeenCalledWith("!ls");
+ expect(sendMessage).not.toHaveBeenCalled();
+ expect(handleCommand).not.toHaveBeenCalled();
+ });
+
+ it("treats a lone ! as a normal message", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const handleCommand = vi.fn();
+ const sendMessage = vi.fn();
+ const handleBangLine = vi.fn();
+
+ const onSubmit = createEditorSubmitHandler({
+ editor,
+ handleCommand,
+ sendMessage,
+ handleBangLine,
+ });
+
+ onSubmit("!");
+
+ expect(handleBangLine).not.toHaveBeenCalled();
+ expect(sendMessage).toHaveBeenCalledTimes(1);
+ expect(sendMessage).toHaveBeenCalledWith("!");
+ });
+
+ it("does not trim input", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const handleCommand = vi.fn();
+ const sendMessage = vi.fn();
+ const handleBangLine = vi.fn();
+
+ const onSubmit = createEditorSubmitHandler({
+ editor,
+ handleCommand,
+ sendMessage,
+ handleBangLine,
+ });
+
+ onSubmit(" hello ");
+
+ expect(sendMessage).toHaveBeenCalledWith(" hello ");
+ expect(editor.addToHistory).toHaveBeenCalledWith(" hello ");
+ });
+});
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index bf777f133..af5d1499e 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -6,6 +6,7 @@ import {
Text,
TUI,
} from "@mariozechner/pi-tui";
+import { spawn } from "node:child_process";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import {
@@ -21,6 +22,7 @@ import { GatewayChatClient } from "./gateway-chat.js";
import { editorTheme, theme } from "./theme/theme.js";
import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js";
+import { createSearchableSelectList } from "./components/selectors.js";
import { formatTokens } from "./tui-formatters.js";
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
import { createOverlayHandlers } from "./tui-overlays.js";
@@ -43,19 +45,30 @@ export function createEditorSubmitHandler(params: {
};
handleCommand: (value: string) => Promise | void;
sendMessage: (value: string) => Promise | void;
+ handleBangLine: (value: string) => Promise | void;
}) {
return (text: string) => {
- const value = text.trim();
+ // NOTE: We intentionally do not trim here.
+ // The caller decides how to interpret whitespace.
+ const value = text;
params.editor.setText("");
if (!value) return;
// Enable built-in editor prompt history navigation (up/down).
params.editor.addToHistory(value);
+ // Bash mode: only if the very first character is '!' and it's not just '!'.
+ // Per requirement: a lone '!' should be treated as a normal message.
+ if (value.startsWith("!") && value !== "!") {
+ void params.handleBangLine(value);
+ return;
+ }
+
if (value.startsWith("/")) {
void params.handleCommand(value);
return;
}
+
void params.sendMessage(value);
};
}
@@ -77,6 +90,12 @@ export async function runTui(opts: TuiOptions) {
let isConnected = false;
let toolsExpanded = false;
let showThinking = false;
+
+ // Local "bash mode" (lines starting with '!') state.
+ // Permission is asked once per TUI session.
+ let localExecAsked = false;
+ let localExecAllowed = false;
+
const deliverDefault = opts.deliver ?? false;
const autoMessage = opts.message?.trim();
let autoMessageSent = false;
@@ -496,11 +515,105 @@ export async function runTui(opts: TuiOptions) {
formatSessionKey,
});
+ const ensureLocalExecAllowed = async (): Promise => {
+ if (localExecAllowed) return true;
+ if (localExecAsked) return false;
+ localExecAsked = true;
+
+ return await new Promise((resolve) => {
+ chatLog.addSystem("Allow local shell commands for this session?");
+ chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
+ const selector = createSearchableSelectList(
+ [
+ { value: "no", label: "No" },
+ { value: "yes", label: "Yes" },
+ ],
+ 2,
+ );
+ selector.onSelect = (item) => {
+ closeOverlay();
+ if (item.value === "yes") {
+ localExecAllowed = true;
+ chatLog.addSystem("local shell: enabled for this session");
+ resolve(true);
+ } else {
+ chatLog.addSystem("local shell: not enabled");
+ resolve(false);
+ }
+ tui.requestRender();
+ };
+ selector.onCancel = () => {
+ closeOverlay();
+ chatLog.addSystem("local shell: cancelled");
+ tui.requestRender();
+ resolve(false);
+ };
+ openOverlay(selector);
+ tui.requestRender();
+ });
+ };
+
+ const runLocalShellLine = async (line: string) => {
+ // line starts with '!'
+ const cmd = line.slice(1);
+ // NOTE: A lone '!' is handled by the submit handler as a normal message.
+ // Keep this guard anyway in case this is called directly.
+ if (cmd === "") return;
+
+ const allowed = await ensureLocalExecAllowed();
+ if (!allowed) return;
+
+ chatLog.addSystem(`[local] $ ${cmd}`);
+ tui.requestRender();
+
+ await new Promise((resolve) => {
+ const child = spawn(cmd, {
+ shell: true,
+ cwd: process.cwd(),
+ env: process.env,
+ });
+
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (buf) => {
+ stdout += buf.toString("utf8");
+ });
+ child.stderr.on("data", (buf) => {
+ stderr += buf.toString("utf8");
+ });
+
+ child.on("close", (code, signal) => {
+ const maxChars = 40_000;
+ const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""))
+ .slice(0, maxChars)
+ .trimEnd();
+
+ if (combined) {
+ for (const line of combined.split("\n")) {
+ chatLog.addSystem(`[local] ${line}`);
+ }
+ }
+ chatLog.addSystem(
+ `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`,
+ );
+ tui.requestRender();
+ resolve();
+ });
+
+ child.on("error", (err) => {
+ chatLog.addSystem(`[local] error: ${String(err)}`);
+ tui.requestRender();
+ resolve();
+ });
+ });
+ };
+
updateAutocompleteProvider();
editor.onSubmit = createEditorSubmitHandler({
editor,
handleCommand,
sendMessage,
+ handleBangLine: runLocalShellLine,
});
editor.onEscape = () => {
From 110b5dafee3834de7dae425281630e73cdc27bf6 Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Thu, 22 Jan 2026 11:41:19 -0800
Subject: [PATCH 37/86] tui: keep trimming for normal submits; only raw !
triggers bash
---
Swabble/Package.resolved | 38 +++++++++++++++++++++++++++++-
src/tui/tui-input-history.test.ts | 4 ++--
src/tui/tui.submit-handler.test.ts | 6 ++---
src/tui/tui.ts | 23 ++++++++++--------
4 files changed, 55 insertions(+), 16 deletions(-)
diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved
index 24de6ea3a..9b86a2df4 100644
--- a/Swabble/Package.resolved
+++ b/Swabble/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
+ "originHash" : "10946d10a137b295c88bae6eb5b1d11f637a00bde46464b19cbf059f77d05e6f",
"pins" : [
{
"identity" : "commander",
@@ -10,6 +10,24 @@
"version" : "0.2.1"
}
},
+ {
+ "identity" : "elevenlabskit",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/steipete/ElevenLabsKit",
+ "state" : {
+ "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
+ "version" : "0.1.0"
+ }
+ },
+ {
+ "identity" : "swift-concurrency-extras",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
+ "state" : {
+ "revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
+ "version" : "1.3.2"
+ }
+ },
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
@@ -27,6 +45,24 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
+ },
+ {
+ "identity" : "swiftui-math",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/swiftui-math",
+ "state" : {
+ "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
+ "version" : "0.1.0"
+ }
+ },
+ {
+ "identity" : "textual",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/gonzalezreal/textual",
+ "state" : {
+ "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
+ "version" : "0.2.0"
+ }
}
],
"version" : 3
diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts
index 18333e1fa..cbebeab7d 100644
--- a/src/tui/tui-input-history.test.ts
+++ b/src/tui/tui-input-history.test.ts
@@ -22,7 +22,7 @@ describe("createEditorSubmitHandler", () => {
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
});
- it("does not trim input before adding to history", () => {
+ it("trims input before adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
@@ -37,7 +37,7 @@ describe("createEditorSubmitHandler", () => {
handler(" hi ");
- expect(editor.addToHistory).toHaveBeenCalledWith(" hi ");
+ expect(editor.addToHistory).toHaveBeenCalledWith("hi");
});
it("does not add empty-string submissions to history", () => {
diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts
index 427c1baa4..928061f1f 100644
--- a/src/tui/tui.submit-handler.test.ts
+++ b/src/tui/tui.submit-handler.test.ts
@@ -50,7 +50,7 @@ describe("createEditorSubmitHandler", () => {
expect(sendMessage).toHaveBeenCalledWith("!");
});
- it("does not trim input", () => {
+ it("trims normal messages before sending and adding to history", () => {
const editor = {
setText: vi.fn(),
addToHistory: vi.fn(),
@@ -68,7 +68,7 @@ describe("createEditorSubmitHandler", () => {
onSubmit(" hello ");
- expect(sendMessage).toHaveBeenCalledWith(" hello ");
- expect(editor.addToHistory).toHaveBeenCalledWith(" hello ");
+ expect(sendMessage).toHaveBeenCalledWith("hello");
+ expect(editor.addToHistory).toHaveBeenCalledWith("hello");
});
});
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index af5d1499e..95c444981 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -48,22 +48,25 @@ export function createEditorSubmitHandler(params: {
handleBangLine: (value: string) => Promise | void;
}) {
return (text: string) => {
- // NOTE: We intentionally do not trim here.
- // The caller decides how to interpret whitespace.
- const value = text;
+ const raw = text;
+ const value = raw.trim();
params.editor.setText("");
+
+ // Keep previous behavior: ignore empty/whitespace-only submissions.
if (!value) return;
+ // Bash mode: only if the very first character is '!' and it's not just '!'.
+ // IMPORTANT: use the raw (untrimmed) text so leading spaces do NOT trigger.
+ // Per requirement: a lone '!' should be treated as a normal message.
+ if (raw.startsWith("!") && raw !== "!") {
+ params.editor.addToHistory(raw);
+ void params.handleBangLine(raw);
+ return;
+ }
+
// Enable built-in editor prompt history navigation (up/down).
params.editor.addToHistory(value);
- // Bash mode: only if the very first character is '!' and it's not just '!'.
- // Per requirement: a lone '!' should be treated as a normal message.
- if (value.startsWith("!") && value !== "!") {
- void params.handleBangLine(value);
- return;
- }
-
if (value.startsWith("/")) {
void params.handleCommand(value);
return;
From dc66527114df330c6b387a6bdb9acf85e10bd2e7 Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Thu, 22 Jan 2026 11:57:44 -0800
Subject: [PATCH 38/86] tui: clarify local shell exec consent prompt
---
src/tui/tui.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index 95c444981..9ea188f7a 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -525,6 +525,9 @@ export async function runTui(opts: TuiOptions) {
return await new Promise((resolve) => {
chatLog.addSystem("Allow local shell commands for this session?");
+ chatLog.addSystem(
+ "This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.",
+ );
chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
const selector = createSearchableSelectList(
[
From 6a25e23909cc679b7723fffd4cc27280bb131406 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:38:44 +0000
Subject: [PATCH 39/86] fix: tui local shell consent UX (#1463)
- add local shell runner + denial notice + tests
- docs: describe ! local shell usage
- lint: drop unused Slack upload contentType
- cleanup: remove stray Swabble pins
Thanks @vignesh07.
Co-authored-by: Vignesh Natarajan
---
CHANGELOG.md | 1 +
Swabble/Package.resolved | 38 +-------
docs/tui.md | 6 ++
src/slack/send.ts | 2 +-
src/tui/tui-input-history.test.ts | 18 ++++
src/tui/tui-local-shell.test.ts | 54 ++++++++++++
src/tui/tui-local-shell.ts | 137 +++++++++++++++++++++++++++++
src/tui/tui.submit-handler.test.ts | 23 +++++
src/tui/tui.ts | 110 ++---------------------
9 files changed, 248 insertions(+), 141 deletions(-)
create mode 100644 src/tui/tui-local-shell.test.ts
create mode 100644 src/tui/tui-local-shell.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36155416c..897fbd7fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased)
### Changes
+- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
diff --git a/Swabble/Package.resolved b/Swabble/Package.resolved
index 9b86a2df4..24de6ea3a 100644
--- a/Swabble/Package.resolved
+++ b/Swabble/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "10946d10a137b295c88bae6eb5b1d11f637a00bde46464b19cbf059f77d05e6f",
+ "originHash" : "c0677e232394b5f6b0191b6dbb5bae553d55264f65ae725cd03a8ffdfda9cdd3",
"pins" : [
{
"identity" : "commander",
@@ -10,24 +10,6 @@
"version" : "0.2.1"
}
},
- {
- "identity" : "elevenlabskit",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/steipete/ElevenLabsKit",
- "state" : {
- "revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
- "version" : "0.1.0"
- }
- },
- {
- "identity" : "swift-concurrency-extras",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/pointfreeco/swift-concurrency-extras",
- "state" : {
- "revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
- "version" : "1.3.2"
- }
- },
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
@@ -45,24 +27,6 @@
"revision" : "399f76dcd91e4c688ca2301fa24a8cc6d9927211",
"version" : "0.99.0"
}
- },
- {
- "identity" : "swiftui-math",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/gonzalezreal/swiftui-math",
- "state" : {
- "revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
- "version" : "0.1.0"
- }
- },
- {
- "identity" : "textual",
- "kind" : "remoteSourceControl",
- "location" : "https://github.com/gonzalezreal/textual",
- "state" : {
- "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
- "version" : "0.2.0"
- }
}
],
"version" : 3
diff --git a/docs/tui.md b/docs/tui.md
index 1c94aee1d..e67b22032 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -88,6 +88,12 @@ Session lifecycle:
- `/settings`
- `/exit`
+## Local shell commands
+- Prefix a line with `!` to run a local shell command on the TUI host.
+- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+- Commands run in a fresh, non-interactive shell in the TUI working directory (no persistent `cd`/env).
+- A lone `!` is sent as a normal message; leading spaces do not trigger local exec.
+
## Tool output
- Tool calls show as cards with args + results.
- Ctrl+O toggles between collapsed/expanded views.
diff --git a/src/slack/send.ts b/src/slack/send.ts
index cfc6c4707..a2ba695c2 100644
--- a/src/slack/send.ts
+++ b/src/slack/send.ts
@@ -88,7 +88,7 @@ async function uploadSlackFile(params: {
threadTs?: string;
maxBytes?: number;
}): Promise {
- const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes);
+ const { buffer, fileName } = await loadWebMedia(params.mediaUrl, params.maxBytes);
const basePayload = {
channel_id: params.channelId,
file: buffer,
diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts
index cbebeab7d..858e599a0 100644
--- a/src/tui/tui-input-history.test.ts
+++ b/src/tui/tui-input-history.test.ts
@@ -58,6 +58,24 @@ describe("createEditorSubmitHandler", () => {
expect(editor.addToHistory).not.toHaveBeenCalled();
});
+ it("does not add whitespace-only submissions to history", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+
+ const handler = createEditorSubmitHandler({
+ editor,
+ handleCommand: vi.fn(),
+ sendMessage: vi.fn(),
+ handleBangLine: vi.fn(),
+ });
+
+ handler(" ");
+
+ expect(editor.addToHistory).not.toHaveBeenCalled();
+ });
+
it("routes slash commands to handleCommand", () => {
const editor = {
setText: vi.fn(),
diff --git a/src/tui/tui-local-shell.test.ts b/src/tui/tui-local-shell.test.ts
new file mode 100644
index 000000000..1e600ef6a
--- /dev/null
+++ b/src/tui/tui-local-shell.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createLocalShellRunner } from "./tui-local-shell.js";
+
+const createSelector = () => {
+ const selector = {
+ onSelect: undefined as ((item: { value: string; label: string }) => void) | undefined,
+ onCancel: undefined as (() => void) | undefined,
+ render: () => ["selector"],
+ invalidate: () => {},
+ };
+ return selector;
+};
+
+describe("createLocalShellRunner", () => {
+ it("logs denial on subsequent ! attempts without re-prompting", async () => {
+ const messages: string[] = [];
+ const chatLog = {
+ addSystem: (line: string) => {
+ messages.push(line);
+ },
+ };
+ const tui = { requestRender: vi.fn() };
+ const openOverlay = vi.fn();
+ const closeOverlay = vi.fn();
+ let lastSelector: ReturnType | null = null;
+ const createSelectorSpy = vi.fn(() => {
+ lastSelector = createSelector();
+ return lastSelector;
+ });
+ const spawnCommand = vi.fn();
+
+ const { runLocalShellLine } = createLocalShellRunner({
+ chatLog,
+ tui,
+ openOverlay,
+ closeOverlay,
+ createSelector: createSelectorSpy,
+ spawnCommand,
+ });
+
+ const firstRun = runLocalShellLine("!ls");
+ expect(openOverlay).toHaveBeenCalledTimes(1);
+ lastSelector?.onSelect?.({ value: "no", label: "No" });
+ await firstRun;
+
+ await runLocalShellLine("!pwd");
+
+ expect(messages).toContain("local shell: not enabled");
+ expect(messages).toContain("local shell: not enabled for this session");
+ expect(createSelectorSpy).toHaveBeenCalledTimes(1);
+ expect(spawnCommand).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/tui/tui-local-shell.ts b/src/tui/tui-local-shell.ts
new file mode 100644
index 000000000..296862c30
--- /dev/null
+++ b/src/tui/tui-local-shell.ts
@@ -0,0 +1,137 @@
+import type { Component, SelectItem } from "@mariozechner/pi-tui";
+import { spawn } from "node:child_process";
+import { createSearchableSelectList } from "./components/selectors.js";
+
+type LocalShellDeps = {
+ chatLog: {
+ addSystem: (line: string) => void;
+ };
+ tui: {
+ requestRender: () => void;
+ };
+ openOverlay: (component: Component) => void;
+ closeOverlay: () => void;
+ createSelector?: (
+ items: SelectItem[],
+ maxVisible: number,
+ ) => Component & {
+ onSelect?: (item: SelectItem) => void;
+ onCancel?: () => void;
+ };
+ spawnCommand?: typeof spawn;
+ getCwd?: () => string;
+ env?: NodeJS.ProcessEnv;
+ maxOutputChars?: number;
+};
+
+export function createLocalShellRunner(deps: LocalShellDeps) {
+ let localExecAsked = false;
+ let localExecAllowed = false;
+ const createSelector = deps.createSelector ?? createSearchableSelectList;
+ const spawnCommand = deps.spawnCommand ?? spawn;
+ const getCwd = deps.getCwd ?? (() => process.cwd());
+ const env = deps.env ?? process.env;
+ const maxChars = deps.maxOutputChars ?? 40_000;
+
+ const ensureLocalExecAllowed = async (): Promise => {
+ if (localExecAllowed) return true;
+ if (localExecAsked) return false;
+ localExecAsked = true;
+
+ return await new Promise((resolve) => {
+ deps.chatLog.addSystem("Allow local shell commands for this session?");
+ deps.chatLog.addSystem(
+ "This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.",
+ );
+ deps.chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
+ const selector = createSelector(
+ [
+ { value: "no", label: "No" },
+ { value: "yes", label: "Yes" },
+ ],
+ 2,
+ );
+ selector.onSelect = (item) => {
+ deps.closeOverlay();
+ if (item.value === "yes") {
+ localExecAllowed = true;
+ deps.chatLog.addSystem("local shell: enabled for this session");
+ resolve(true);
+ } else {
+ deps.chatLog.addSystem("local shell: not enabled");
+ resolve(false);
+ }
+ deps.tui.requestRender();
+ };
+ selector.onCancel = () => {
+ deps.closeOverlay();
+ deps.chatLog.addSystem("local shell: cancelled");
+ deps.tui.requestRender();
+ resolve(false);
+ };
+ deps.openOverlay(selector);
+ deps.tui.requestRender();
+ });
+ };
+
+ const runLocalShellLine = async (line: string) => {
+ const cmd = line.slice(1);
+ // NOTE: A lone '!' is handled by the submit handler as a normal message.
+ // Keep this guard anyway in case this is called directly.
+ if (cmd === "") return;
+
+ if (localExecAsked && !localExecAllowed) {
+ deps.chatLog.addSystem("local shell: not enabled for this session");
+ deps.tui.requestRender();
+ return;
+ }
+
+ const allowed = await ensureLocalExecAllowed();
+ if (!allowed) return;
+
+ deps.chatLog.addSystem(`[local] $ ${cmd}`);
+ deps.tui.requestRender();
+
+ await new Promise((resolve) => {
+ const child = spawnCommand(cmd, {
+ shell: true,
+ cwd: getCwd(),
+ env,
+ });
+
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (buf) => {
+ stdout += buf.toString("utf8");
+ });
+ child.stderr.on("data", (buf) => {
+ stderr += buf.toString("utf8");
+ });
+
+ child.on("close", (code, signal) => {
+ const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""))
+ .slice(0, maxChars)
+ .trimEnd();
+
+ if (combined) {
+ for (const line of combined.split("\n")) {
+ deps.chatLog.addSystem(`[local] ${line}`);
+ }
+ }
+ deps.chatLog.addSystem(
+ `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`,
+ );
+ deps.tui.requestRender();
+ resolve();
+ });
+
+ child.on("error", (err) => {
+ deps.chatLog.addSystem(`[local] error: ${String(err)}`);
+ deps.tui.requestRender();
+ resolve();
+ });
+ });
+ };
+
+ return { runLocalShellLine };
+}
diff --git a/src/tui/tui.submit-handler.test.ts b/src/tui/tui.submit-handler.test.ts
index 928061f1f..799f382e2 100644
--- a/src/tui/tui.submit-handler.test.ts
+++ b/src/tui/tui.submit-handler.test.ts
@@ -50,6 +50,29 @@ describe("createEditorSubmitHandler", () => {
expect(sendMessage).toHaveBeenCalledWith("!");
});
+ it("does not treat leading whitespace before ! as a bang command", () => {
+ const editor = {
+ setText: vi.fn(),
+ addToHistory: vi.fn(),
+ };
+ const handleCommand = vi.fn();
+ const sendMessage = vi.fn();
+ const handleBangLine = vi.fn();
+
+ const onSubmit = createEditorSubmitHandler({
+ editor,
+ handleCommand,
+ sendMessage,
+ handleBangLine,
+ });
+
+ onSubmit(" !ls");
+
+ expect(handleBangLine).not.toHaveBeenCalled();
+ expect(sendMessage).toHaveBeenCalledWith("!ls");
+ expect(editor.addToHistory).toHaveBeenCalledWith("!ls");
+ });
+
it("trims normal messages before sending and adding to history", () => {
const editor = {
setText: vi.fn(),
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index 9ea188f7a..69f6e779c 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -6,7 +6,6 @@ import {
Text,
TUI,
} from "@mariozechner/pi-tui";
-import { spawn } from "node:child_process";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import {
@@ -22,8 +21,8 @@ import { GatewayChatClient } from "./gateway-chat.js";
import { editorTheme, theme } from "./theme/theme.js";
import { createCommandHandlers } from "./tui-command-handlers.js";
import { createEventHandlers } from "./tui-event-handlers.js";
-import { createSearchableSelectList } from "./components/selectors.js";
import { formatTokens } from "./tui-formatters.js";
+import { createLocalShellRunner } from "./tui-local-shell.js";
import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js";
import { createOverlayHandlers } from "./tui-overlays.js";
import { createSessionActions } from "./tui-session-actions.js";
@@ -94,11 +93,6 @@ export async function runTui(opts: TuiOptions) {
let toolsExpanded = false;
let showThinking = false;
- // Local "bash mode" (lines starting with '!') state.
- // Permission is asked once per TUI session.
- let localExecAsked = false;
- let localExecAllowed = false;
-
const deliverDefault = opts.deliver ?? false;
const autoMessage = opts.message?.trim();
let autoMessageSent = false;
@@ -518,102 +512,12 @@ export async function runTui(opts: TuiOptions) {
formatSessionKey,
});
- const ensureLocalExecAllowed = async (): Promise => {
- if (localExecAllowed) return true;
- if (localExecAsked) return false;
- localExecAsked = true;
-
- return await new Promise((resolve) => {
- chatLog.addSystem("Allow local shell commands for this session?");
- chatLog.addSystem(
- "This runs commands on YOUR machine (not the gateway) and may delete files or reveal secrets.",
- );
- chatLog.addSystem("Select Yes/No (arrows + Enter), Esc to cancel.");
- const selector = createSearchableSelectList(
- [
- { value: "no", label: "No" },
- { value: "yes", label: "Yes" },
- ],
- 2,
- );
- selector.onSelect = (item) => {
- closeOverlay();
- if (item.value === "yes") {
- localExecAllowed = true;
- chatLog.addSystem("local shell: enabled for this session");
- resolve(true);
- } else {
- chatLog.addSystem("local shell: not enabled");
- resolve(false);
- }
- tui.requestRender();
- };
- selector.onCancel = () => {
- closeOverlay();
- chatLog.addSystem("local shell: cancelled");
- tui.requestRender();
- resolve(false);
- };
- openOverlay(selector);
- tui.requestRender();
- });
- };
-
- const runLocalShellLine = async (line: string) => {
- // line starts with '!'
- const cmd = line.slice(1);
- // NOTE: A lone '!' is handled by the submit handler as a normal message.
- // Keep this guard anyway in case this is called directly.
- if (cmd === "") return;
-
- const allowed = await ensureLocalExecAllowed();
- if (!allowed) return;
-
- chatLog.addSystem(`[local] $ ${cmd}`);
- tui.requestRender();
-
- await new Promise((resolve) => {
- const child = spawn(cmd, {
- shell: true,
- cwd: process.cwd(),
- env: process.env,
- });
-
- let stdout = "";
- let stderr = "";
- child.stdout.on("data", (buf) => {
- stdout += buf.toString("utf8");
- });
- child.stderr.on("data", (buf) => {
- stderr += buf.toString("utf8");
- });
-
- child.on("close", (code, signal) => {
- const maxChars = 40_000;
- const combined = (stdout + (stderr ? (stdout ? "\n" : "") + stderr : ""))
- .slice(0, maxChars)
- .trimEnd();
-
- if (combined) {
- for (const line of combined.split("\n")) {
- chatLog.addSystem(`[local] ${line}`);
- }
- }
- chatLog.addSystem(
- `[local] exit ${code ?? "?"}${signal ? ` (signal ${String(signal)})` : ""}`,
- );
- tui.requestRender();
- resolve();
- });
-
- child.on("error", (err) => {
- chatLog.addSystem(`[local] error: ${String(err)}`);
- tui.requestRender();
- resolve();
- });
- });
- };
-
+ const { runLocalShellLine } = createLocalShellRunner({
+ chatLog,
+ tui,
+ openOverlay,
+ closeOverlay,
+ });
updateAutocompleteProvider();
editor.onSubmit = createEditorSubmitHandler({
editor,
From d297e17958f0329e83b7376669e4aaa2f707ac67 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Thu, 22 Jan 2026 23:41:28 +0000
Subject: [PATCH 40/86] refactor: centralize control ui avatar helpers
---
src/commands/onboard-helpers.ts | 2 +-
src/commands/status-all.ts | 2 +-
src/commands/status.scan.ts | 2 +-
src/gateway/control-ui-shared.ts | 49 +++++++++++++++++++++++++
src/gateway/control-ui.test.ts | 29 ++++++++++++++-
src/gateway/control-ui.ts | 55 +++++-----------------------
src/gateway/server-methods/agent.ts | 2 +-
src/gateway/server-runtime-config.ts | 2 +-
8 files changed, 92 insertions(+), 51 deletions(-)
create mode 100644 src/gateway/control-ui-shared.ts
diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts
index 2e1ac091c..bd45bd755 100644
--- a/src/commands/onboard-helpers.ts
+++ b/src/commands/onboard-helpers.ts
@@ -10,7 +10,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js";
-import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
+import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { isWSL } from "../infra/wsl.js";
diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts
index 81845a309..26c6e415d 100644
--- a/src/commands/status-all.ts
+++ b/src/commands/status-all.ts
@@ -7,7 +7,7 @@ import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
-import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
+import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts
index 860192325..13de0a383 100644
--- a/src/commands/status.scan.ts
+++ b/src/commands/status.scan.ts
@@ -1,7 +1,7 @@
import { withProgress } from "../cli/progress.js";
import { loadConfig } from "../config/config.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
-import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
+import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { probeGateway } from "../gateway/probe.js";
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js";
diff --git a/src/gateway/control-ui-shared.ts b/src/gateway/control-ui-shared.ts
new file mode 100644
index 000000000..b29de0a03
--- /dev/null
+++ b/src/gateway/control-ui-shared.ts
@@ -0,0 +1,49 @@
+const CONTROL_UI_AVATAR_PREFIX = "/avatar";
+
+export function normalizeControlUiBasePath(basePath?: string): string {
+ if (!basePath) return "";
+ let normalized = basePath.trim();
+ if (!normalized) return "";
+ if (!normalized.startsWith("/")) normalized = `/${normalized}`;
+ if (normalized === "/") return "";
+ if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
+ return normalized;
+}
+
+export function buildControlUiAvatarUrl(basePath: string, agentId: string): string {
+ return basePath
+ ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/${agentId}`
+ : `${CONTROL_UI_AVATAR_PREFIX}/${agentId}`;
+}
+
+function looksLikeLocalAvatarPath(value: string): boolean {
+ if (/[\\/]/.test(value)) return true;
+ return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
+}
+
+export function resolveAssistantAvatarUrl(params: {
+ avatar?: string | null;
+ agentId?: string | null;
+ basePath?: string;
+}): string | undefined {
+ const avatar = params.avatar?.trim();
+ if (!avatar) return undefined;
+ if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
+
+ const basePath = normalizeControlUiBasePath(params.basePath);
+ const baseAvatarPrefix = basePath
+ ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/`
+ : `${CONTROL_UI_AVATAR_PREFIX}/`;
+ if (basePath && avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) {
+ return `${basePath}${avatar}`;
+ }
+ if (avatar.startsWith(baseAvatarPrefix)) return avatar;
+
+ if (!params.agentId) return avatar;
+ if (looksLikeLocalAvatarPath(avatar)) {
+ return buildControlUiAvatarUrl(basePath, params.agentId);
+ }
+ return avatar;
+}
+
+export { CONTROL_UI_AVATAR_PREFIX };
diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts
index 5149830f3..06719d276 100644
--- a/src/gateway/control-ui.test.ts
+++ b/src/gateway/control-ui.test.ts
@@ -1,8 +1,26 @@
import { describe, expect, it } from "vitest";
-import { resolveAssistantAvatarUrl } from "./control-ui.js";
+import {
+ buildControlUiAvatarUrl,
+ normalizeControlUiBasePath,
+ resolveAssistantAvatarUrl,
+} from "./control-ui-shared.js";
describe("resolveAssistantAvatarUrl", () => {
+ it("normalizes base paths", () => {
+ expect(normalizeControlUiBasePath()).toBe("");
+ expect(normalizeControlUiBasePath("")).toBe("");
+ expect(normalizeControlUiBasePath(" ")).toBe("");
+ expect(normalizeControlUiBasePath("/")).toBe("");
+ expect(normalizeControlUiBasePath("ui")).toBe("/ui");
+ expect(normalizeControlUiBasePath("/ui/")).toBe("/ui");
+ });
+
+ it("builds avatar URLs", () => {
+ expect(buildControlUiAvatarUrl("", "main")).toBe("/avatar/main");
+ expect(buildControlUiAvatarUrl("/ui", "main")).toBe("/ui/avatar/main");
+ });
+
it("keeps remote and data URLs", () => {
expect(
resolveAssistantAvatarUrl({
@@ -54,6 +72,15 @@ describe("resolveAssistantAvatarUrl", () => {
).toBe("/ui/avatar/main");
});
+ it("leaves local paths untouched when agentId is missing", () => {
+ expect(
+ resolveAssistantAvatarUrl({
+ avatar: "avatars/me.png",
+ basePath: "/ui",
+ }),
+ ).toBe("avatars/me.png");
+ });
+
it("keeps short text avatars", () => {
expect(
resolveAssistantAvatarUrl({
diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts
index f51a49111..1034b4f6d 100644
--- a/src/gateway/control-ui.ts
+++ b/src/gateway/control-ui.ts
@@ -5,9 +5,14 @@ import { fileURLToPath } from "node:url";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
+import {
+ buildControlUiAvatarUrl,
+ CONTROL_UI_AVATAR_PREFIX,
+ normalizeControlUiBasePath,
+ resolveAssistantAvatarUrl,
+} from "./control-ui-shared.js";
const ROOT_PREFIX = "/";
-const AVATAR_PREFIX = "/avatar";
export type ControlUiRequestOptions = {
basePath?: string;
@@ -15,16 +20,6 @@ export type ControlUiRequestOptions = {
agentId?: string;
};
-export function normalizeControlUiBasePath(basePath?: string): string {
- if (!basePath) return "";
- let normalized = basePath.trim();
- if (!normalized) return "";
- if (!normalized.startsWith("/")) normalized = `/${normalized}`;
- if (normalized === "/") return "";
- if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
- return normalized;
-}
-
function resolveControlUiRoot(): string | null {
const here = path.dirname(fileURLToPath(import.meta.url));
const execDir = (() => {
@@ -98,10 +93,6 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
-export function buildAvatarUrl(basePath: string, agentId: string): string {
- return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
-}
-
function isValidAgentId(agentId: string): boolean {
return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId);
}
@@ -118,7 +109,9 @@ export function handleControlUiAvatarRequest(
const url = new URL(urlRaw, "http://localhost");
const basePath = normalizeControlUiBasePath(opts.basePath);
const pathname = url.pathname;
- const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
+ const pathWithBase = basePath
+ ? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/`
+ : `${CONTROL_UI_AVATAR_PREFIX}/`;
if (!pathname.startsWith(pathWithBase)) return false;
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
@@ -132,7 +125,7 @@ export function handleControlUiAvatarRequest(
const resolved = opts.resolveAvatar(agentId);
const avatarUrl =
resolved.kind === "local"
- ? buildAvatarUrl(basePath, agentId)
+ ? buildControlUiAvatarUrl(basePath, agentId)
: resolved.kind === "remote" || resolved.kind === "data"
? resolved.url
: null;
@@ -206,34 +199,6 @@ interface ServeIndexHtmlOpts {
agentId?: string;
}
-function looksLikeLocalAvatarPath(value: string): boolean {
- if (/[\\/]/.test(value)) return true;
- return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
-}
-
-export function resolveAssistantAvatarUrl(params: {
- avatar?: string | null;
- agentId?: string | null;
- basePath?: string;
-}): string | undefined {
- const avatar = params.avatar?.trim();
- if (!avatar) return undefined;
- if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
-
- const basePath = normalizeControlUiBasePath(params.basePath);
- const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`;
- if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) {
- return `${basePath}${avatar}`;
- }
- if (avatar.startsWith(baseAvatarPrefix)) return avatar;
-
- if (!params.agentId) return avatar;
- if (looksLikeLocalAvatarPath(avatar)) {
- return buildAvatarUrl(basePath, params.agentId);
- }
- return avatar;
-}
-
function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts;
const identity = config
diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts
index 636272845..e015e1d17 100644
--- a/src/gateway/server-methods/agent.ts
+++ b/src/gateway/server-methods/agent.ts
@@ -38,7 +38,7 @@ import {
import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js";
-import { resolveAssistantAvatarUrl } from "../control-ui.js";
+import { resolveAssistantAvatarUrl } from "../control-ui-shared.js";
import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js";
diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts
index c8b4a1721..a155c5d0a 100644
--- a/src/gateway/server-runtime-config.ts
+++ b/src/gateway/server-runtime-config.ts
@@ -9,7 +9,7 @@ import {
type ResolvedGatewayAuth,
resolveGatewayAuth,
} from "./auth.js";
-import { normalizeControlUiBasePath } from "./control-ui.js";
+import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { resolveHooksConfig } from "./hooks.js";
import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";
From 870bfa94ed1dc991bae685ee1c4e528af14d72b0 Mon Sep 17 00:00:00 2001
From: Peter Steinberger