From 955a2901c8aac2a8f6560297e40f3d369f47f54d Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Tue, 10 Mar 2026 23:58:13 +0900 Subject: [PATCH] feat: add 'both' agent type for shared Claude Code + Codex channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AgentType now supports 'both' — runs both agents sequentially - Each agent gets its own folder suffix (_cc / _codex) for isolated sessions - Claude Code bot handles message storage for 'both' channels - Responses routed through the matching bot for each agent type --- src/channels/discord.ts | 6 ++- src/index.ts | 101 ++++++++++++++++++++++++++++------------ src/types.ts | 2 +- 3 files changed, 76 insertions(+), 33 deletions(-) diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 83ddf3d..7e27d85 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -249,7 +249,11 @@ export class DiscordChannel implements Channel { if (!this.agentTypeFilter) return true; const group = this.opts.registeredGroups()[jid]; if (!group) return false; - return (group.agentType || 'claude-code') === this.agentTypeFilter; + const groupType = group.agentType || 'claude-code'; + // 'both' channels are owned by the claude-code bot (primary handler for + // message storage and typing). The codex bot skips them to avoid duplicates. + if (groupType === 'both') return this.agentTypeFilter === 'claude-code'; + return groupType === this.agentTypeFilter; } async disconnect(): Promise { diff --git a/src/index.ts b/src/index.ts index c0667ac..7c0512b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -239,46 +239,86 @@ async function processGroupMessages(chatJid: string): Promise { }, IDLE_TIMEOUT); }; - await channel.setTyping?.(chatJid, true); + // Determine which agent types to run + const agentTypes: Array<'claude-code' | 'codex'> = + group.agentType === 'both' + ? ['claude-code', 'codex'] + : [group.agentType || 'claude-code']; + let hadError = false; let outputSentToUser = false; - const output = await runAgent(group, prompt, chatJid, async (result) => { - // Streaming output callback — called for each agent result - if (result.result) { - const raw = - typeof result.result === 'string' - ? result.result - : JSON.stringify(result.result); - // Strip ... blocks — agent uses these for internal reasoning - const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); - logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); - if (text) { - await channel.sendMessage(chatJid, text); - outputSentToUser = true; - } - // Clear typing indicator after sending a response — the runner may stay - // alive waiting for IPC messages, but the user shouldn't see "typing..." - await channel.setTyping?.(chatJid, false); - // Only reset idle timer on actual results, not session-update markers (result: null) - resetIdleTimer(); - } + for (const agentType of agentTypes) { + // For 'both' groups, create a variant group with the specific agent type + // and a separate folder so each agent has its own session/workspace + const effectiveGroup: RegisteredGroup = + group.agentType === 'both' + ? { + ...group, + agentType, + folder: `${group.folder}_${agentType === 'claude-code' ? 'cc' : 'codex'}`, + } + : group; - if (result.status === 'success') { - queue.notifyIdle(chatJid); - } + // Find the right channel to send responses through + const sendChannel = + group.agentType === 'both' + ? channels.find( + (c) => + c.name.includes('discord') && + c.name.includes( + agentType === 'codex' ? 'codex' : 'claude-code', + ), + ) || channel + : channel; - if (result.status === 'error') { + await sendChannel.setTyping?.(chatJid, true); + + const output = await runAgent( + effectiveGroup, + prompt, + chatJid, + async (result) => { + if (result.result) { + const raw = + typeof result.result === 'string' + ? result.result + : JSON.stringify(result.result); + const text = raw + .replace(/[\s\S]*?<\/internal>/g, '') + .trim(); + logger.info( + { group: group.name, agentType }, + `Agent output: ${raw.slice(0, 200)}`, + ); + if (text) { + await sendChannel.sendMessage(chatJid, text); + outputSentToUser = true; + } + await sendChannel.setTyping?.(chatJid, false); + resetIdleTimer(); + } + + if (result.status === 'success') { + queue.notifyIdle(chatJid); + } + + if (result.status === 'error') { + hadError = true; + } + }, + ); + + await sendChannel.setTyping?.(chatJid, false); + + if (output === 'error') { hadError = true; } - }); + } - await channel.setTyping?.(chatJid, false); if (idleTimer) clearTimeout(idleTimer); - if (output === 'error' || hadError) { - // If we already sent output to the user, don't roll back the cursor — - // the user got their response and re-processing would send duplicates. + if (hadError) { if (outputSentToUser) { logger.warn( { group: group.name }, @@ -286,7 +326,6 @@ async function processGroupMessages(chatJid: string): Promise { ); return true; } - // Roll back cursor so retries can re-process these messages lastAgentTimestamp[chatJid] = previousCursor; saveState(); logger.warn( diff --git a/src/types.ts b/src/types.ts index 2801f34..2a36e75 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,7 @@ export interface ContainerConfig { timeout?: number; // Default: 300000 (5 minutes) } -export type AgentType = 'claude-code' | 'codex'; +export type AgentType = 'claude-code' | 'codex' | 'both'; export interface RegisteredGroup { name: string;