diff --git a/docs/channels/matrix-js.md b/docs/channels/matrix-js.md index c1a4ad262df..eb20345b9b8 100644 --- a/docs/channels/matrix-js.md +++ b/docs/channels/matrix-js.md @@ -242,7 +242,7 @@ Matrix-js supports native Matrix threads for both automatic replies and message- - Inbound threaded messages include the thread root message as extra agent context. - Message-tool sends now auto-inherit the current Matrix thread when the target is the same room, or the same DM user target, unless an explicit `threadId` is provided. - Runtime thread bindings are supported for Matrix-js. `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`, and thread-bound `/acp spawn` now work in Matrix rooms and DMs. -- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session. +- Top-level Matrix room/DM `/focus` creates a new Matrix thread and binds it to the target session when `threadBindings.spawnSubagentSessions=true`. - Running `/focus` or `/acp spawn --thread here` inside an existing Matrix thread binds that current thread instead. ### Thread Binding Config @@ -255,7 +255,10 @@ Matrix-js inherits global defaults from `session.threadBindings`, and also suppo - `threadBindings.spawnSubagentSessions` - `threadBindings.spawnAcpSessions` -For Matrix-js, spawn flags default to enabled unless you turn them off explicitly. +Matrix-js thread-bound spawn flags are opt-in: + +- Set `threadBindings.spawnSubagentSessions: true` to allow top-level `/focus` to create and bind new Matrix threads. +- Set `threadBindings.spawnAcpSessions: true` to allow `/acp spawn --thread auto|here` to bind ACP sessions to Matrix threads. ## Reactions diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index db5077aebcf..b832a9e062f 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -651,6 +651,7 @@ Run multiple accounts per channel (each with its own `accountId`): ### Other extension channels Many extension channels are configured as `channels.` and documented in their dedicated channel pages (for example Feishu, Matrix, LINE, Nostr, Zalo, Nextcloud Talk, Synology Chat, and Twitch). +Matrix-js also supports top-level `bindings[]` entries with `type: "acp"` for persistent ACP bindings. Use the Matrix room id or Matrix thread root event id in `match.peer.id`. See the full channel index: [Channels](/channels). ### Group chat mention gating @@ -1358,7 +1359,7 @@ Run multiple isolated agents inside one Gateway. See [Multi-Agent](/concepts/mul Within each tier, the first matching `bindings` entry wins. -For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above. +For `type: "acp"` entries, OpenClaw resolves by exact conversation identity (`match.channel` + account + `match.peer.id`) and does not use the route binding tier order above. For example, use a Discord channel/thread id, a Matrix room id or thread root event id, or a Telegram canonical topic id. ### Per-agent access profiles diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 65a320f1c52..0e80c7a6216 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -79,6 +79,7 @@ Required feature flags for thread-bound ACP: - `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch) - Channel-adapter ACP thread-spawn flag enabled (adapter-specific) - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + - Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ### Thread supporting channels @@ -86,6 +87,7 @@ Required feature flags for thread-bound ACP: - Any channel adapter that exposes session/thread binding capability. - Current built-in support: - Discord threads/channels + - Matrix-js room threads and DMs - Telegram topics (forum topics in groups/supergroups and DM topics) - Plugin channels can add support through the same binding interface. @@ -98,6 +100,7 @@ For non-ephemeral workflows, configure persistent ACP bindings in top-level `bin - `bindings[].type="acp"` marks a persistent ACP conversation binding. - `bindings[].match` identifies the target conversation: - Discord channel or thread: `match.channel="discord"` + `match.peer.id=""` + - Matrix room or thread: `match.channel="matrix-js"` + `match.peer.id=""` - Telegram forum topic: `match.channel="telegram"` + `match.peer.id=":topic:"` - `bindings[].agentId` is the owning OpenClaw agent id. - Optional ACP overrides live under `bindings[].acp`: @@ -375,6 +378,7 @@ Notes: - On non-thread binding surfaces, default behavior is effectively `off`. - Thread-bound spawn requires channel policy support: - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` + - Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` - Telegram: `channels.telegram.threadBindings.spawnAcpSessions=true` ## ACP controls @@ -475,7 +479,7 @@ Core ACP baseline: } ``` -Thread binding config is channel-adapter specific. Example for Discord: +Thread binding config is channel-adapter specific. Example for Discord and Matrix-js: ```json5 { @@ -493,6 +497,12 @@ Thread binding config is channel-adapter specific. Example for Discord: spawnAcpSessions: true, }, }, + "matrix-js": { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, }, } ``` @@ -500,6 +510,7 @@ Thread binding config is channel-adapter specific. Example for Discord: If thread-bound ACP spawn does not work, verify the adapter feature flag first: - Discord: `channels.discord.threadBindings.spawnAcpSessions=true` +- Matrix-js: `channels["matrix-js"].threadBindings.spawnAcpSessions=true` See [Configuration Reference](/gateway/configuration-reference). diff --git a/docs/tools/subagents.md b/docs/tools/subagents.md index dabfc91dfc2..08a2917b598 100644 --- a/docs/tools/subagents.md +++ b/docs/tools/subagents.md @@ -99,7 +99,11 @@ When thread bindings are enabled for a channel, a sub-agent can stay bound to a ### Thread supporting channels -- Discord (currently the only supported channel): supports persistent thread-bound subagent sessions (`sessions_spawn` with `thread: true`), manual thread controls (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`), and adapter keys `channels.discord.threadBindings.enabled`, `channels.discord.threadBindings.idleHours`, `channels.discord.threadBindings.maxAgeHours`, and `channels.discord.threadBindings.spawnSubagentSessions`. +- `sessions_spawn` with `thread: true`: currently supported on Discord only. +- Manual thread/conversation controls: + - Discord: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age` + - Matrix-js: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age` + - Telegram: `/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age` Quick flow: diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index c53584cdf55..9d651803b6a 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -963,6 +963,18 @@ describe("spawnAcpDirect", () => { }); it("keeps inline delivery for thread-bound ACP session mode", async () => { + hoisted.state.cfg = { + ...hoisted.state.cfg, + channels: { + ...hoisted.state.cfg.channels, + telegram: { + threadBindings: { + spawnAcpSessions: true, + }, + }, + }, + }; + const result = await spawnAcpDirect( { task: "Investigate flaky tests", diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 7447419fd1e..cec949634b5 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -243,9 +243,10 @@ function createSessionBindingCapabilities() { type AcpBindInput = { targetSessionKey: string; conversation: { - channel?: "discord" | "telegram"; + channel?: "discord" | "matrix-js" | "telegram"; accountId: string; conversationId: string; + parentConversationId?: string; }; placement: "current" | "child"; metadata?: Record; @@ -266,11 +267,18 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding { conversationId: nextConversationId, parentConversationId: "parent-1", } - : { - channel: "telegram", - accountId: input.conversation.accountId, - conversationId: nextConversationId, - }, + : channel === "matrix-js" + ? { + channel: "matrix-js", + accountId: input.conversation.accountId, + conversationId: nextConversationId, + parentConversationId: input.conversation.parentConversationId ?? "!room:example", + } + : { + channel: "telegram", + accountId: input.conversation.accountId, + conversationId: nextConversationId, + }, metadata: { boundBy, webhookId: "wh-1" }, }); } @@ -334,6 +342,24 @@ function createTelegramDmParams(commandBody: string, cfg: OpenClawConfig = baseC return params; } +function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix-js", + Surface: "matrix-js", + OriginatingChannel: "matrix-js", + OriginatingTo: "room:!room:example", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + +function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = createMatrixRoomParams(commandBody, cfg); + params.ctx.MessageThreadId = "$thread-42"; + return params; +} + async function runDiscordAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { return handleAcpCommand(createDiscordParams(commandBody, cfg), true); } @@ -350,6 +376,14 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true); } +async function runMatrixRoomAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true); +} + +async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true); +} + describe("/acp command", () => { beforeEach(() => { acpManagerTesting.resetAcpSessionManagerForTests(); @@ -518,7 +552,18 @@ describe("/acp command", () => { }); it("binds Telegram topic ACP spawns to full conversation ids", async () => { - const result = await runTelegramAcpCommand("/acp spawn codex --thread here"); + const cfg = { + ...baseCfg, + channels: { + ...baseCfg.channels, + telegram: { + threadBindings: { + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + const result = await runTelegramAcpCommand("/acp spawn codex --thread here", cfg); expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); expect(result?.reply?.text).toContain("Bound this conversation to"); @@ -536,7 +581,18 @@ describe("/acp command", () => { }); it("binds Telegram DM ACP spawns to the DM conversation id", async () => { - const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here"); + const cfg = { + ...baseCfg, + channels: { + ...baseCfg.channels, + telegram: { + threadBindings: { + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + const result = await runTelegramDmAcpCommand("/acp spawn codex --thread here", cfg); expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); expect(result?.reply?.text).toContain("Bound this conversation to"); @@ -592,6 +648,47 @@ describe("/acp command", () => { ); }); + it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is not enabled", async () => { + const result = await runMatrixRoomAcpCommand("/acp spawn codex --thread auto"); + + expect(result?.reply?.text).toContain( + "channels.matrix-js.threadBindings.spawnAcpSessions=true", + ); + expect(hoisted.closeMock).toHaveBeenCalledTimes(1); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + + it("binds Matrix thread-bound ACP spawns when enabled explicitly", async () => { + const cfg = { + ...baseCfg, + channels: { + ...baseCfg.channels, + "matrix-js": { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg); + + expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:"); + expect(result?.reply?.text).toContain("Bound this thread to"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "matrix-js", + accountId: "default", + conversationId: "$thread-42", + parentConversationId: "!room:example", + }), + }), + ); + }); + it("forbids /acp spawn from sandboxed requester sessions", async () => { const cfg = { ...baseCfg, diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 564788f78d7..8cb880381b9 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -157,6 +157,9 @@ async function bindSpawnedAcpSessionToThread(params: { channel: spawnPolicy.channel, accountId: spawnPolicy.accountId, conversationId: currentConversationId, + ...(bindingContext.parentConversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" @@ -181,6 +184,9 @@ async function bindSpawnedAcpSessionToThread(params: { channel: spawnPolicy.channel, accountId: spawnPolicy.accountId, conversationId, + ...(bindingContext.parentConversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }, placement, metadata: { diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index b118bf99793..13b904bfec6 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -105,8 +105,8 @@ function createTelegramTopicCommandParams(commandBody: string) { return params; } -function createMatrixCommandParams(commandBody: string) { - const params = buildCommandTestParams(commandBody, baseCfg, { +function createMatrixCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { Provider: "matrix-js", Surface: "matrix-js", OriginatingChannel: "matrix-js", @@ -236,7 +236,17 @@ describe("/focus, /unfocus, /agents", () => { }); it("/focus creates Matrix child thread bindings from top-level rooms", async () => { - const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp")); + const cfg = { + ...baseCfg, + channels: { + "matrix-js": { + threadBindings: { + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp", cfg)); expect(result?.reply?.text).toContain("created thread"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( @@ -251,6 +261,15 @@ describe("/focus, /unfocus, /agents", () => { ); }); + it("/focus rejects Matrix child thread creation when spawn config is not enabled", async () => { + const result = await focusCodexAcp(createMatrixCommandParams("/focus codex-acp")); + + expect(result?.reply?.text).toContain( + "channels.matrix-js.threadBindings.spawnAcpSessions=true", + ); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + it("/focus includes ACP session identifiers in intro text when available", async () => { hoisted.readAcpSessionEntryMock.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index 79da5ca5dc9..425ba7bca49 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -8,8 +8,10 @@ import { resolveThreadBindingThreadName, } from "../../../channels/thread-bindings-messages.js"; import { + formatThreadBindingSpawnDisabledError, resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, } from "../../../channels/thread-bindings-policy.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; @@ -183,6 +185,23 @@ export async function handleSubagentsFocusAction( if (!capabilities.placements.includes(bindingContext.placement)) { return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`); } + if (bindingContext.channel === "matrix-js" && bindingContext.placement === "child") { + const spawnPolicy = resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: bindingContext.channel, + accountId, + kind: focusTarget.targetKind === "acp" ? "acp" : "subagent", + }); + if (!spawnPolicy.spawnEnabled) { + return stopWithText( + `⚠️ ${formatThreadBindingSpawnDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: focusTarget.targetKind === "acp" ? "acp" : "subagent", + })}`, + ); + } + } let binding; try { diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts new file mode 100644 index 00000000000..890fb22d393 --- /dev/null +++ b/src/channels/thread-bindings-policy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveThreadBindingSpawnPolicy } from "./thread-bindings-policy.js"; + +const baseCfg = { + session: { mainKey: "main", scope: "per-sender" }, +} satisfies OpenClawConfig; + +describe("resolveThreadBindingSpawnPolicy", () => { + it("defaults thread-bound spawns to opt-in across supported channels", () => { + expect( + resolveThreadBindingSpawnPolicy({ + cfg: baseCfg, + channel: "discord", + kind: "subagent", + }).spawnEnabled, + ).toBe(false); + expect( + resolveThreadBindingSpawnPolicy({ + cfg: baseCfg, + channel: "matrix-js", + kind: "subagent", + }).spawnEnabled, + ).toBe(false); + expect( + resolveThreadBindingSpawnPolicy({ + cfg: baseCfg, + channel: "telegram", + kind: "acp", + }).spawnEnabled, + ).toBe(false); + }); + + it("honors explicit per-channel spawn flags", () => { + const cfg = { + ...baseCfg, + channels: { + "matrix-js": { + threadBindings: { + spawnSubagentSessions: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + expect( + resolveThreadBindingSpawnPolicy({ + cfg, + channel: "matrix-js", + kind: "subagent", + }).spawnEnabled, + ).toBe(true); + expect( + resolveThreadBindingSpawnPolicy({ + cfg, + channel: "matrix-js", + kind: "acp", + }).spawnEnabled, + ).toBe(true); + }); +}); diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 15f3f5557fe..3334ecaea03 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -2,6 +2,8 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; +export const MATRIX_JS_THREAD_BINDING_CHANNEL = "matrix-js"; +export const TELEGRAM_THREAD_BINDING_CHANNEL = "telegram"; const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -106,6 +108,22 @@ function resolveSpawnFlagKey( return kind === "subagent" ? "spawnSubagentSessions" : "spawnAcpSessions"; } +function resolveSpawnConfigPath(params: { + channel: string; + kind: ThreadBindingSpawnKind; +}): string | undefined { + const suffix = + params.kind === "subagent" ? "spawnSubagentSessions=true" : "spawnAcpSessions=true"; + if ( + params.channel === DISCORD_THREAD_BINDING_CHANNEL || + params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL || + params.channel === TELEGRAM_THREAD_BINDING_CHANNEL + ) { + return `channels.${params.channel}.threadBindings.${suffix}`; + } + return undefined; +} + export function resolveThreadBindingSpawnPolicy(params: { cfg: OpenClawConfig; channel: string; @@ -127,8 +145,7 @@ export function resolveThreadBindingSpawnPolicy(params: { const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]); - // Non-Discord channels currently have no dedicated spawn gate config keys. - const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL; + const spawnEnabled = spawnEnabledRaw ?? false; return { channel, accountId, @@ -191,11 +208,21 @@ export function formatThreadBindingSpawnDisabledError(params: { accountId: string; kind: ThreadBindingSpawnKind; }): string { - if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "acp") { - return "Discord thread-bound ACP spawns are disabled for this account (set channels.discord.threadBindings.spawnAcpSessions=true to enable)."; - } - if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") { - return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable)."; + const configPath = resolveSpawnConfigPath({ + channel: params.channel, + kind: params.kind, + }); + const label = + params.channel === DISCORD_THREAD_BINDING_CHANNEL + ? "Discord" + : params.channel === MATRIX_JS_THREAD_BINDING_CHANNEL + ? "Matrix-js" + : params.channel === TELEGRAM_THREAD_BINDING_CHANNEL + ? "Telegram" + : params.channel; + if (configPath) { + const noun = params.kind === "acp" ? "ACP" : "subagent"; + return `${label} thread-bound ${noun} spawns are disabled for this account (set ${configPath} to enable).`; } return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`; } diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 085c5b407d9..d711ee7c382 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1550,9 +1550,9 @@ export const FIELD_HELP: Record = { "channels.matrix-js.threadBindings.maxAgeHours": "Optional hard max age in hours for Matrix-js thread-bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set.", "channels.matrix-js.threadBindings.spawnSubagentSessions": - "Allow subagent spawns/focus flows to auto-create and bind Matrix threads when starting from a top-level Matrix room or DM.", + "Allow top-level /focus flows to auto-create and bind Matrix threads for subagent/session targets (default: false; opt-in). Set true to enable Matrix thread creation/binding from room or DM contexts.", "channels.matrix-js.threadBindings.spawnAcpSessions": - "Allow /acp spawn to auto-create and bind Matrix threads for ACP sessions when starting from a top-level Matrix room or DM.", + "Allow /acp spawn to create or bind Matrix threads for ACP sessions (default: false; opt-in). Set true to enable thread-bound ACP spawns for this account/channel.", "channels.discord.ui.components.accentColor": "Accent color for Discord component containers (hex). Set per account via channels.discord.accounts..ui.components.accentColor.", "channels.discord.voice.enabled":