From 922cba6775ae50f1c515ed88c2192ae5bd339f2c Mon Sep 17 00:00:00 2001 From: Tio Date: Fri, 13 Mar 2026 21:46:19 -0400 Subject: [PATCH 1/2] Fix IRC proactive sends: use monitor's persistent client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sendMessageIrc function was creating transient IRC connections for proactive sends. These transient clients don't join channels before sending PRIVMSG, so messages to channels silently fail. This adds an active client registry that the monitor populates when it connects. sendMessageIrc now checks for an active persistent client first, falling back to transient connections only when no monitor client is available. This enables agents to initiate conversations in IRC channels from their main session — a prerequisite for autonomy. Co-Authored-By: Claude Opus 4.6 (1M context) --- extensions/irc/src/active-clients.ts | 24 ++++++++++++++++++++++++ extensions/irc/src/monitor.ts | 4 ++++ extensions/irc/src/send.ts | 21 ++++++++++++++------- 3 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 extensions/irc/src/active-clients.ts diff --git a/extensions/irc/src/active-clients.ts b/extensions/irc/src/active-clients.ts new file mode 100644 index 00000000000..47ac90c747f --- /dev/null +++ b/extensions/irc/src/active-clients.ts @@ -0,0 +1,24 @@ +import type { IrcClient } from "./client.js"; + +/** + * Registry of active IRC clients from the monitor. + * Keyed by accountId. Allows sendMessageIrc to use the persistent + * monitor client instead of creating a transient connection. + */ +const activeClients = new Map(); + +export function setActiveClient(accountId: string, client: IrcClient): void { + activeClients.set(accountId, client); +} + +export function getActiveClient(accountId: string): IrcClient | undefined { + const client = activeClients.get(accountId); + if (client && client.isReady()) { + return client; + } + return undefined; +} + +export function removeActiveClient(accountId: string): void { + activeClients.delete(accountId); +} diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index 2a75b76ee08..8ba5fd95338 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,5 +1,6 @@ import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared"; import { resolveIrcAccount } from "./accounts.js"; +import { setActiveClient, removeActiveClient } from "./active-clients.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; import { handleIrcInbound } from "./inbound.js"; @@ -136,8 +137,11 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto `[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`, ); + setActiveClient(account.accountId, client); + return { stop: () => { + removeActiveClient(account.accountId); client?.quit("shutdown"); client = null; }, diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 544f81f3f47..8b832cf1e28 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -1,4 +1,5 @@ import { resolveIrcAccount } from "./accounts.js"; +import { getActiveClient } from "./active-clients.js"; import type { IrcClient } from "./client.js"; import { connectIrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; @@ -67,13 +68,19 @@ export async function sendMessageIrc( if (client?.isReady()) { client.sendPrivmsg(target, payload); } else { - const transient = await connectIrcClient( - buildIrcConnectOptions(account, { - connectTimeoutMs: 12000, - }), - ); - transient.sendPrivmsg(target, payload); - transient.quit("sent"); + // Try the monitor's persistent client first (already connected and joined to channels) + const active = getActiveClient(account.accountId); + if (active) { + active.sendPrivmsg(target, payload); + } else { + const transient = await connectIrcClient( + buildIrcConnectOptions(account, { + connectTimeoutMs: 12000, + }), + ); + transient.sendPrivmsg(target, payload); + transient.quit("sent"); + } } runtime.channel.activity.record({ From 7d72001bb94a91bfc895b98b6ec325fe2b1f5e58 Mon Sep 17 00:00:00 2001 From: Tio Date: Sat, 14 Mar 2026 07:03:44 -0400 Subject: [PATCH 2/2] Address review: identity-check on removeActiveClient Use removeActiveClientIfMatch to only deregister the active client if it matches the instance owned by the stopping monitor. Prevents a reconnect race where the first monitor's stop() would deregister the second monitor's healthy client. Co-Authored-By: Claude Opus 4.6 (1M context) --- extensions/irc/src/active-clients.ts | 11 +++++++++++ extensions/irc/src/monitor.ts | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/extensions/irc/src/active-clients.ts b/extensions/irc/src/active-clients.ts index 47ac90c747f..cd960e201be 100644 --- a/extensions/irc/src/active-clients.ts +++ b/extensions/irc/src/active-clients.ts @@ -22,3 +22,14 @@ export function getActiveClient(accountId: string): IrcClient | undefined { export function removeActiveClient(accountId: string): void { activeClients.delete(accountId); } + +/** + * Only remove the active client if it matches the expected instance. + * Prevents a stopping monitor from deregistering a newer monitor's + * healthy client during reconnect races. + */ +export function removeActiveClientIfMatch(accountId: string, expected: IrcClient): void { + if (activeClients.get(accountId) === expected) { + activeClients.delete(accountId); + } +} diff --git a/extensions/irc/src/monitor.ts b/extensions/irc/src/monitor.ts index 8ba5fd95338..6eb487f586f 100644 --- a/extensions/irc/src/monitor.ts +++ b/extensions/irc/src/monitor.ts @@ -1,6 +1,6 @@ import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared"; import { resolveIrcAccount } from "./accounts.js"; -import { setActiveClient, removeActiveClient } from "./active-clients.js"; +import { setActiveClient, removeActiveClientIfMatch } from "./active-clients.js"; import { connectIrcClient, type IrcClient } from "./client.js"; import { buildIrcConnectOptions } from "./connect-options.js"; import { handleIrcInbound } from "./inbound.js"; @@ -141,7 +141,9 @@ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ sto return { stop: () => { - removeActiveClient(account.accountId); + if (client) { + removeActiveClientIfMatch(account.accountId, client); + } client?.quit("shutdown"); client = null; },