From e4d38fa7994f2168742e163ca5fcccfac9e0eba1 Mon Sep 17 00:00:00 2001 From: Tio Date: Fri, 13 Mar 2026 21:23:48 -0400 Subject: [PATCH 1/5] Add groupScope config for routing group/channel messages to main session When session.groupScope is set to "main", group and channel messages (IRC channels, Discord channels, etc.) route to the agent's main session instead of creating isolated per-channel sessions. Default remains "per-channel" (existing behavior unchanged). This enables agents in the Clawmune to see IRC #clawmune messages in their main session context, solving the session fragmentation problem where each channel creates an isolated session the agent's primary interaction never sees. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/routing/resolve-route.ts | 9 +++++++++ src/routing/session-key.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index f56fdc1319d..7e814f3d357 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -95,6 +95,8 @@ export function buildAgentSessionKey(params: { peer?: RoutePeer | null; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; + /** Group/channel session scope. When "main", group messages route to the main session. */ + groupScope?: "main" | "per-channel"; identityLinks?: Record; }): string { const channel = normalizeToken(params.channel) || "unknown"; @@ -107,6 +109,7 @@ export function buildAgentSessionKey(params: { peerKind: peer?.kind ?? "direct", peerId: peer ? normalizeId(peer.id) || "unknown" : null, dmScope: params.dmScope, + groupScope: params.groupScope, identityLinks: params.identityLinks, }); } @@ -625,6 +628,11 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const memberRoleIds = input.memberRoleIds ?? []; const memberRoleIdSet = new Set(memberRoleIds); const dmScope = input.cfg.session?.dmScope ?? "main"; + const groupScope = + ((input.cfg.session as Record | undefined)?.groupScope as + | "main" + | "per-channel" + | undefined) ?? "per-channel"; const identityLinks = input.cfg.session?.identityLinks; const shouldLogDebug = shouldLogVerbose(); const parentPeer = input.parentPeer @@ -666,6 +674,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR accountId, peer, dmScope, + groupScope, identityLinks, }).toLowerCase(); const mainSessionKey = buildAgentMainSessionKey({ diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 5a24c63c3a4..2fe4b7c76f7 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -134,6 +134,8 @@ export function buildAgentPeerSessionKey(params: { identityLinks?: Record; /** DM session scope. */ dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer"; + /** Group/channel session scope. When "main", group messages route to the main session. */ + groupScope?: "main" | "per-channel"; }): string { const peerKind = params.peerKind ?? "direct"; if (peerKind === "direct") { @@ -168,6 +170,14 @@ export function buildAgentPeerSessionKey(params: { mainKey: params.mainKey, }); } + // For group/channel peer kinds: if groupScope is "main", collapse to main session + const groupScope = params.groupScope ?? "per-channel"; + if (groupScope === "main") { + return buildAgentMainSessionKey({ + agentId: params.agentId, + mainKey: params.mainKey, + }); + } const channel = (params.channel ?? "").trim().toLowerCase() || "unknown"; const peerId = ((params.peerId ?? "").trim() || "unknown").toLowerCase(); return `agent:${normalizeAgentId(params.agentId)}:${channel}:${peerKind}:${peerId}`; From ce2d9be050d7414a921c391d3098f5171e33a07d Mon Sep 17 00:00:00 2001 From: Tio Date: Fri, 13 Mar 2026 21:25:39 -0400 Subject: [PATCH 2/5] Add groupScope to session config schema Allows session.groupScope in openclaw.json without Zod validation rejecting it as an unknown key. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config/zod-schema.session.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 3f4b6a24d80..c2b3d280d51 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -34,6 +34,7 @@ export const SessionSchema = z z.literal("per-account-channel-peer"), ]) .optional(), + groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), From 746b1c465115c990b7e08ce52375966b1e853cd0 Mon Sep 17 00:00:00 2001 From: Tio Date: Fri, 13 Mar 2026 21:46:19 -0400 Subject: [PATCH 3/5] 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 98f9c4f74f07827c2c738d1feb8e1d5ca45ecc37 Mon Sep 17 00:00:00 2001 From: Tio Date: Fri, 13 Mar 2026 22:05:12 -0400 Subject: [PATCH 4/5] Refactor groupScope to per-binding instead of global session config Move groupScope from session.groupScope (global, affects all channels) to bindings[].groupScope (per-binding, surgical control). This allows specific channel/peer combinations to route to the main session while keeping all other group channels isolated. Example: bindings: [{ agentId: "main", match: { channel: "irc", peer: { kind: "channel", id: "#clawmune" } }, groupScope: "main" }] This merges only #clawmune into main while #random stays isolated. Privacy boundaries are preserved by default (per-channel). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/config/types.agents.ts | 2 ++ src/config/zod-schema.agents.ts | 1 + src/config/zod-schema.session.ts | 1 - src/routing/resolve-route.ts | 16 ++++++++-------- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts index a979506a2ab..a806fe05f32 100644 --- a/src/config/types.agents.ts +++ b/src/config/types.agents.ts @@ -41,6 +41,8 @@ export type AgentRouteBinding = { agentId: string; comment?: string; match: AgentBindingMatch; + /** When "main", group/channel messages matching this binding route to the agent's main session. */ + groupScope?: "main" | "per-channel"; }; export type AgentAcpBinding = { diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 5dddfc9813a..8c732f9a16d 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -40,6 +40,7 @@ const RouteBindingSchema = z agentId: z.string(), comment: z.string().optional(), match: BindingMatchSchema, + groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(), }) .strict(); diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index c2b3d280d51..3f4b6a24d80 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -34,7 +34,6 @@ export const SessionSchema = z z.literal("per-account-channel-peer"), ]) .optional(), - groupScope: z.union([z.literal("main"), z.literal("per-channel")]).optional(), identityLinks: z.record(z.string(), z.array(z.string())).optional(), resetTriggers: z.array(z.string()).optional(), idleMinutes: z.number().int().positive().optional(), diff --git a/src/routing/resolve-route.ts b/src/routing/resolve-route.ts index 7e814f3d357..bd5ab8bd68d 100644 --- a/src/routing/resolve-route.ts +++ b/src/routing/resolve-route.ts @@ -628,11 +628,6 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const memberRoleIds = input.memberRoleIds ?? []; const memberRoleIdSet = new Set(memberRoleIds); const dmScope = input.cfg.session?.dmScope ?? "main"; - const groupScope = - ((input.cfg.session as Record | undefined)?.groupScope as - | "main" - | "per-channel" - | undefined) ?? "per-channel"; const identityLinks = input.cfg.session?.identityLinks; const shouldLogDebug = shouldLogVerbose(); const parentPeer = input.parentPeer @@ -666,7 +661,11 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR const bindings = getEvaluatedBindingsForChannelAccount(input.cfg, channel, accountId); const bindingsIndex = getEvaluatedBindingIndexForChannelAccount(input.cfg, channel, accountId); - const choose = (agentId: string, matchedBy: ResolvedAgentRoute["matchedBy"]) => { + const choose = ( + agentId: string, + matchedBy: ResolvedAgentRoute["matchedBy"], + bindingGroupScope?: "main" | "per-channel", + ) => { const resolvedAgentId = pickFirstExistingAgentId(input.cfg, agentId); const sessionKey = buildAgentSessionKey({ agentId: resolvedAgentId, @@ -674,7 +673,7 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR accountId, peer, dmScope, - groupScope, + groupScope: bindingGroupScope ?? "per-channel", identityLinks, }).toLowerCase(); const mainSessionKey = buildAgentMainSessionKey({ @@ -805,7 +804,8 @@ export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentR if (shouldLogDebug) { logDebug(`[routing] match: matchedBy=${tier.matchedBy} agentId=${matched.binding.agentId}`); } - return choose(matched.binding.agentId, tier.matchedBy); + const routeBinding = matched.binding as { groupScope?: "main" | "per-channel" }; + return choose(matched.binding.agentId, tier.matchedBy, routeBinding.groupScope); } } From 18db51f77c7a3883de404a19f4e7a6023a223793 Mon Sep 17 00:00:00 2001 From: Tio Date: Sat, 14 Mar 2026 07:03:44 -0400 Subject: [PATCH 5/5] 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; },