feat: add 'both' agent type for shared Claude Code + Codex channels

- 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
This commit is contained in:
Eyejoker
2026-03-10 23:58:13 +09:00
parent f02761eddb
commit 955a2901c8
3 changed files with 76 additions and 33 deletions

View File

@@ -249,7 +249,11 @@ export class DiscordChannel implements Channel {
if (!this.agentTypeFilter) return true; if (!this.agentTypeFilter) return true;
const group = this.opts.registeredGroups()[jid]; const group = this.opts.registeredGroups()[jid];
if (!group) return false; 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<void> { async disconnect(): Promise<void> {

View File

@@ -239,46 +239,86 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
}, IDLE_TIMEOUT); }, 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 hadError = false;
let outputSentToUser = false; let outputSentToUser = false;
const output = await runAgent(group, prompt, chatJid, async (result) => { for (const agentType of agentTypes) {
// Streaming output callback — called for each agent result // For 'both' groups, create a variant group with the specific agent type
if (result.result) { // and a separate folder so each agent has its own session/workspace
const raw = const effectiveGroup: RegisteredGroup =
typeof result.result === 'string' group.agentType === 'both'
? result.result ? {
: JSON.stringify(result.result); ...group,
// Strip <internal>...</internal> blocks — agent uses these for internal reasoning agentType,
const text = raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim(); folder: `${group.folder}_${agentType === 'claude-code' ? 'cc' : 'codex'}`,
logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`); }
if (text) { : group;
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();
}
if (result.status === 'success') { // Find the right channel to send responses through
queue.notifyIdle(chatJid); 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(/<internal>[\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; hadError = true;
} }
}); }
await channel.setTyping?.(chatJid, false);
if (idleTimer) clearTimeout(idleTimer); if (idleTimer) clearTimeout(idleTimer);
if (output === 'error' || hadError) { if (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 (outputSentToUser) { if (outputSentToUser) {
logger.warn( logger.warn(
{ group: group.name }, { group: group.name },
@@ -286,7 +326,6 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
); );
return true; return true;
} }
// Roll back cursor so retries can re-process these messages
lastAgentTimestamp[chatJid] = previousCursor; lastAgentTimestamp[chatJid] = previousCursor;
saveState(); saveState();
logger.warn( logger.warn(

View File

@@ -32,7 +32,7 @@ export interface ContainerConfig {
timeout?: number; // Default: 300000 (5 minutes) timeout?: number; // Default: 300000 (5 minutes)
} }
export type AgentType = 'claude-code' | 'codex'; export type AgentType = 'claude-code' | 'codex' | 'both';
export interface RegisteredGroup { export interface RegisteredGroup {
name: string; name: string;