diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index 9cd57f6..8685d6f 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -491,6 +491,62 @@ if (allowGenericScheduling) { ); } +server.tool( + 'assign_room', + `Assign or update a room using room-level settings. Main group only. + +This is the room-level replacement for legacy register_group. If folder is omitted, the host generates one automatically.`, + { + jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'), + name: z.string().describe('Display name for the room'), + room_mode: z.enum(['single', 'tribunal']).default('single').describe('single=owner only, tribunal=owner/reviewer roles enabled'), + owner_agent_type: z.enum(['claude-code', 'codex']).optional().describe('Preferred owner agent type for the room'), + folder: z.string().optional().describe('Optional folder override. If omitted, the host generates one automatically.'), + trigger: z.string().optional().describe('Optional canonical trigger label to store for the room'), + requires_trigger: z.boolean().optional().describe('Whether direct trigger mention is required'), + is_main: z.boolean().optional().describe('Whether this room is the privileged main control room'), + work_dir: z.string().optional().describe('Optional working directory override'), + }, + async (args: { + jid: string; + name: string; + room_mode?: 'single' | 'tribunal'; + owner_agent_type?: 'claude-code' | 'codex'; + folder?: string; + trigger?: string; + requires_trigger?: boolean; + is_main?: boolean; + work_dir?: string; + }) => { + if (!isMain) { + return { + content: [{ type: 'text' as const, text: 'Only the main group can assign rooms.' }], + isError: true, + }; + } + + const data = { + type: 'assign_room', + jid: args.jid, + name: args.name, + room_mode: args.room_mode || 'single', + owner_agent_type: args.owner_agent_type, + folder: args.folder, + trigger: args.trigger, + requiresTrigger: args.requires_trigger, + isMain: args.is_main, + workDir: args.work_dir, + timestamp: new Date().toISOString(), + }; + + writeIpcFile(TASKS_DIR, data); + + return { + content: [{ type: 'text' as const, text: `Room "${args.name}" assignment requested.` }], + }; + }, +); + server.tool( 'register_group', `Register a new chat/group so the agent can respond to messages there. Main group only. @@ -522,7 +578,7 @@ Use available_groups.json to find the JID for a group. The folder name must be c writeIpcFile(TASKS_DIR, data); return { - content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], + content: [{ type: 'text' as const, text: `Group "${args.name}" registration requested.` }], }; }, ); diff --git a/src/db.test.ts b/src/db.test.ts index bf2de8e..e56cc10 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, _initTestDatabaseFromFile, + assignRoom, claimServiceHandoff, completeServiceHandoffAndAdvanceTargetCursor, createPairedTask, @@ -774,6 +775,57 @@ describe('registered group isMain', () => { }); }); +describe('room assignment writes', () => { + it('assigns a single room with an auto-generated folder', () => { + const group = assignRoom('tg:-1001', { + name: 'Telegram Dev Team', + roomMode: 'single', + ownerAgentType: 'claude-code', + }); + + expect(group).toBeDefined(); + expect(group!.folder).toMatch(/^grp_telegram_/); + expect(group!.agentType).toBe('claude-code'); + expect(getStoredRoomSettings('tg:-1001')).toMatchObject({ + chatJid: 'tg:-1001', + roomMode: 'single', + modeSource: 'explicit', + name: 'Telegram Dev Team', + ownerAgentType: 'claude-code', + }); + expect(getRegisteredAgentTypesForJid('tg:-1001')).toEqual(['claude-code']); + }); + + it('materializes tribunal capability rows while serving metadata from room_settings', () => { + assignRoom('dc:assigned-room', { + name: 'Assigned Room', + roomMode: 'tribunal', + ownerAgentType: 'codex', + folder: 'assigned-room', + }); + + const allGroups = getAllRegisteredGroups(); + const claudeGroups = getAllRegisteredGroups('claude-code'); + const codexGroups = getAllRegisteredGroups('codex'); + + expect(allGroups['dc:assigned-room']).toMatchObject({ + name: 'Assigned Room', + folder: 'assigned-room', + agentType: 'codex', + }); + expect(claudeGroups['dc:assigned-room']).toMatchObject({ + name: 'Assigned Room', + folder: 'assigned-room', + agentType: 'claude-code', + }); + expect(codexGroups['dc:assigned-room']).toMatchObject({ + name: 'Assigned Room', + folder: 'assigned-room', + agentType: 'codex', + }); + }); +}); + describe('paired room registration', () => { it('detects when both Claude and Codex are registered on the same jid', () => { setRegisteredGroup('dc:123', { diff --git a/src/db.ts b/src/db.ts index 706d50e..d34428b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -59,6 +59,19 @@ export interface StoredRoomSettings { workDir?: string; } +export interface AssignRoomInput { + name: string; + roomMode?: RoomMode; + ownerAgentType?: AgentType; + folder?: string; + trigger?: string; + requiresTrigger?: boolean; + isMain?: boolean; + workDir?: string; + addedAt?: string; + ownerAgentConfig?: RegisteredGroup['agentConfig']; +} + interface RoomRegistrationSnapshot { name: string; folder: string; @@ -69,6 +82,19 @@ interface RoomRegistrationSnapshot { workDir: string | null; } +interface RegisteredGroupDatabaseRow { + jid: string; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + agent_config: string | null; + requires_trigger: number | null; + is_main: number | null; + agent_type: string | null; + work_dir: string | null; +} + export interface WorkItem { id: number; group_folder: string; @@ -1899,53 +1925,66 @@ export function getLastRespondingAgentType( // --- Registered group accessors --- +function buildRegisteredGroupFromStoredSettings( + database: Database, + stored: StoredRoomSettings, + requestedAgentType?: AgentType, +): (RegisteredGroup & { jid: string }) | undefined { + const capabilityTypes = collectRegisteredAgentTypes(database, stored.chatJid); + const resolvedAgentType = requestedAgentType + ? capabilityTypes.includes(requestedAgentType) + ? requestedAgentType + : undefined + : stored.ownerAgentType || + (capabilityTypes.length > 0 + ? inferOwnerAgentTypeFromRegisteredAgentTypes(capabilityTypes) + : undefined); + + if (!resolvedAgentType) return undefined; + if (!stored.folder || !isValidGroupFolder(stored.folder)) { + logger.warn( + { jid: stored.chatJid, folder: stored.folder ?? null }, + 'Skipping stored room with invalid folder', + ); + return undefined; + } + + const capabilityMetadata = getRegisteredGroupCapabilityMetadata( + database, + stored.chatJid, + resolvedAgentType, + ); + + return { + jid: stored.chatJid, + name: stored.name || stored.chatJid, + folder: stored.folder, + trigger: stored.trigger || `@${ASSISTANT_NAME}`, + added_at: + capabilityMetadata?.added_at || new Date(0).toISOString(), + agentConfig: capabilityMetadata?.agentConfig, + requiresTrigger: stored.requiresTrigger, + isMain: stored.isMain ? true : undefined, + agentType: resolvedAgentType, + workDir: stored.workDir, + }; +} + export function getRegisteredGroup( jid: string, agentType?: string, ): (RegisteredGroup & { jid: string }) | undefined { - const row = ( - agentType - ? db - .prepare( - 'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?', - ) - .get(jid, agentType) - : db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid) - ) as - | { - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - agent_config: string | null; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; - work_dir: string | null; - } - | undefined; - if (!row) return undefined; - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', + const requestedAgentType = normalizeStoredAgentType(agentType); + const stored = getStoredRoomSettingsRowFromDatabase(db, jid); + if (stored) { + const group = buildRegisteredGroupFromStoredSettings( + db, + stored, + requestedAgentType, ); - return undefined; + if (group) return group; } - return { - jid: row.jid, - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, - agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, - workDir: row.work_dir || undefined, - }; + return getLegacyRegisteredGroup(db, jid, agentType); } export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { @@ -1978,68 +2017,115 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { tx(); } +export function assignRoom( + chatJid: string, + input: AssignRoomInput, +): (RegisteredGroup & { jid: string }) | undefined { + const existing = getStoredRoomSettingsRowFromDatabase(db, chatJid); + const roomMode = input.roomMode || existing?.roomMode || 'single'; + const ownerAgentType = + input.ownerAgentType || existing?.ownerAgentType || OWNER_AGENT_TYPE; + const folder = resolveAssignedRoomFolder(db, chatJid, input.name, input.folder); + const snapshot: RoomRegistrationSnapshot = { + name: input.name, + folder, + triggerPattern: + input.trigger || existing?.trigger || `@${ASSISTANT_NAME}`, + requiresTrigger: input.requiresTrigger ?? existing?.requiresTrigger ?? true, + isMain: input.isMain ?? existing?.isMain ?? false, + ownerAgentType, + workDir: input.workDir ?? existing?.workDir ?? null, + }; + const now = new Date().toISOString(); + + db.transaction(() => { + if (existing) { + db.prepare( + `UPDATE room_settings + SET room_mode = ?, + mode_source = 'explicit', + name = ?, + folder = ?, + trigger_pattern = ?, + requires_trigger = ?, + is_main = ?, + owner_agent_type = ?, + work_dir = ?, + updated_at = ? + WHERE chat_jid = ?`, + ).run( + roomMode, + snapshot.name, + snapshot.folder, + snapshot.triggerPattern, + snapshot.requiresTrigger ? 1 : 0, + snapshot.isMain ? 1 : 0, + snapshot.ownerAgentType, + snapshot.workDir, + now, + chatJid, + ); + } else { + insertStoredRoomSettings(db, chatJid, roomMode, 'explicit', snapshot); + } + + materializeRegisteredGroupsForRoom( + db, + chatJid, + snapshot, + roomMode, + ownerAgentType, + input.ownerAgentConfig, + input.addedAt ?? now, + ); + })(); + + return getRegisteredGroup(chatJid); +} + export function updateRegisteredGroupName(jid: string, name: string): void { + db.prepare( + `UPDATE room_settings + SET name = ?, updated_at = ? + WHERE chat_jid = ?`, + ).run(name, new Date().toISOString(), jid); db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run( name, jid, ); - syncStoredRoomRegistrationSnapshotForJid(jid); + if (!getStoredRoomSettingsRowFromDatabase(db, jid)) { + syncStoredRoomRegistrationSnapshotForJid(jid); + } } export function getAllRegisteredGroups( agentTypeFilter?: string, ): Record { - const rows = ( - agentTypeFilter - ? db - .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') - .all(agentTypeFilter) - : db.prepare('SELECT * FROM registered_groups').all() - ) as Array<{ - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - agent_config: string | null; - requires_trigger: number | null; - is_main: number | null; - agent_type: string | null; - work_dir: string | null; - }>; const result: Record = {}; - for (const row of rows) { - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - continue; - } - const group: RegisteredGroup = { - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, - agentType: (row.agent_type as RegisteredGroup['agentType']) || undefined, - workDir: row.work_dir || undefined, - }; - // In unified mode (no agentTypeFilter), when the same JID has both - // claude-code and codex rows, prefer the codex entry (owner). - const existing = result[row.jid]; - if (existing && !agentTypeFilter) { - if (group.agentType === 'codex') { - result[row.jid] = group; - } - // else keep existing (already codex or first-seen) - } else { - result[row.jid] = group; + const requestedAgentType = normalizeStoredAgentType(agentTypeFilter); + const storedRows = getStoredRoomRowsFromDatabase(db); + + for (const stored of storedRows) { + const group = buildRegisteredGroupFromStoredSettings( + db, + stored, + requestedAgentType, + ); + if (group) { + const { jid, ...rest } = group; + result[jid] = rest; } } + + for (const legacyRow of getLegacyRegisteredGroupRows(db, agentTypeFilter)) { + if (result[legacyRow.jid]) continue; + const group = parseRegisteredGroupRow(legacyRow); + if (group) { + const { jid, ...rest } = group; + result[jid] = rest; + } + } + return result; } @@ -2107,6 +2193,293 @@ function normalizeStoredAgentType( : undefined; } +function parseRegisteredGroupRow( + row: RegisteredGroupDatabaseRow | undefined, +): (RegisteredGroup & { jid: string }) | undefined { + if (!row) return undefined; + if (!isValidGroupFolder(row.folder)) { + logger.warn( + { jid: row.jid, folder: row.folder }, + 'Skipping registered group with invalid folder', + ); + return undefined; + } + return { + jid: row.jid, + name: row.name, + folder: row.folder, + trigger: row.trigger_pattern, + added_at: row.added_at, + agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + isMain: row.is_main === 1 ? true : undefined, + agentType: normalizeStoredAgentType(row.agent_type), + workDir: row.work_dir || undefined, + }; +} + +function getLegacyRegisteredGroupRows( + database: Database, + agentTypeFilter?: string, +): RegisteredGroupDatabaseRow[] { + return ( + agentTypeFilter + ? database + .prepare('SELECT * FROM registered_groups WHERE agent_type = ?') + .all(agentTypeFilter) + : database.prepare('SELECT * FROM registered_groups').all() + ) as RegisteredGroupDatabaseRow[]; +} + +function getLegacyRegisteredGroup( + database: Database, + jid: string, + agentType?: string, +): (RegisteredGroup & { jid: string }) | undefined { + const row = ( + agentType + ? database + .prepare( + 'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?', + ) + .get(jid, agentType) + : database.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid) + ) as RegisteredGroupDatabaseRow | undefined; + return parseRegisteredGroupRow(row); +} + +function getRegisteredGroupCapabilityMetadata( + database: Database, + jid: string, + preferredAgentType?: AgentType, +): Pick | undefined { + const row = ( + preferredAgentType + ? database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? AND agent_type = ? + LIMIT 1`, + ) + .get(jid, preferredAgentType) + : database + .prepare( + `SELECT added_at, agent_config + FROM registered_groups + WHERE jid = ? + ORDER BY CASE WHEN agent_type = ? THEN 0 ELSE 1 END, added_at + LIMIT 1`, + ) + .get(jid, OWNER_AGENT_TYPE) + ) as { added_at: string; agent_config: string | null } | undefined; + + if (!row) return undefined; + return { + added_at: row.added_at, + agentConfig: row.agent_config ? JSON.parse(row.agent_config) : undefined, + }; +} + +function getStoredRoomRowsFromDatabase(database: Database): StoredRoomSettings[] { + return database + .prepare( + `SELECT chat_jid + FROM room_settings + ORDER BY chat_jid`, + ) + .all() + .map((row) => + getStoredRoomSettingsRowFromDatabase(database, (row as { chat_jid: string }).chat_jid), + ) + .filter((row): row is StoredRoomSettings => Boolean(row)); +} + +function detectChannelPrefixForFolder(chatJid: string): string { + if (chatJid.startsWith('dc:')) return 'discord'; + if (chatJid.startsWith('tg:')) return 'telegram'; + return 'whatsapp'; +} + +function slugifyGroupFolderSegment(value: string): string { + const slug = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .replace(/-{2,}/g, '-'); + return slug || 'room'; +} + +function collectReservedFolders( + database: Database, + exceptChatJid?: string, +): Set { + const folders = new Set(); + const rows = database + .prepare( + `SELECT folder + FROM room_settings + WHERE folder IS NOT NULL + AND (? IS NULL OR chat_jid != ?) + UNION + SELECT folder + FROM registered_groups + WHERE ? IS NULL OR jid != ?`, + ) + .all(exceptChatJid ?? null, exceptChatJid ?? null, exceptChatJid ?? null, exceptChatJid ?? null) as Array<{ + folder: string | null; + }>; + + for (const row of rows) { + if (row.folder) folders.add(row.folder); + } + return folders; +} + +function buildGeneratedRoomFolder( + database: Database, + chatJid: string, + name: string, +): string { + const prefix = detectChannelPrefixForFolder(chatJid); + const slug = slugifyGroupFolderSegment(name); + const fallback = slugifyGroupFolderSegment(chatJid.replace(/[:@.]/g, '-')); + const baseCore = slug || fallback; + const maxBaseLength = 64 - (`grp_${prefix}_`.length + 4); + const truncatedCore = baseCore.slice(0, Math.max(8, maxBaseLength)); + const candidateBase = `grp_${prefix}_${truncatedCore}`; + const reserved = collectReservedFolders(database, chatJid); + + if (!reserved.has(candidateBase) && isValidGroupFolder(candidateBase)) { + return candidateBase; + } + + for (let suffix = 2; suffix < 1000; suffix += 1) { + const candidate = `${candidateBase.slice(0, Math.max(1, 64 - `${suffix}`.length - 1))}-${suffix}`; + if (!reserved.has(candidate) && isValidGroupFolder(candidate)) { + return candidate; + } + } + + throw new Error(`Unable to generate unique group folder for ${chatJid}`); +} + +function resolveAssignedRoomFolder( + database: Database, + chatJid: string, + name: string, + explicitFolder?: string, +): string { + const reserved = collectReservedFolders(database, chatJid); + if (explicitFolder) { + if (!isValidGroupFolder(explicitFolder)) { + throw new Error(`Invalid group folder "${explicitFolder}" for JID ${chatJid}`); + } + if (reserved.has(explicitFolder)) { + throw new Error(`Group folder "${explicitFolder}" is already assigned`); + } + return explicitFolder; + } + + const existingFolder = getStoredRoomSettingsRowFromDatabase(database, chatJid)?.folder; + if (existingFolder) return existingFolder; + + return buildGeneratedRoomFolder(database, chatJid, name); +} + +function getDesiredRegisteredAgentTypes( + roomMode: RoomMode, + ownerAgentType: AgentType, +): AgentType[] { + const types = new Set([ownerAgentType]); + if (roomMode === 'tribunal') { + types.add(REVIEWER_AGENT_TYPE); + } + return [...types]; +} + +function materializeRegisteredGroupsForRoom( + database: Database, + chatJid: string, + snapshot: RoomRegistrationSnapshot, + roomMode: RoomMode, + ownerAgentType: AgentType, + ownerAgentConfig?: RegisteredGroup['agentConfig'], + addedAt?: string, +): void { + const now = new Date().toISOString(); + const desiredTypes = getDesiredRegisteredAgentTypes(roomMode, ownerAgentType); + const existingRows = database + .prepare( + `SELECT agent_type, added_at, agent_config + FROM registered_groups + WHERE jid = ?`, + ) + .all(chatJid) as Array<{ + agent_type: string | null; + added_at: string; + agent_config: string | null; + }>; + const existingByType = new Map(); + for (const row of existingRows) { + const agentType = normalizeStoredAgentType(row.agent_type); + if (agentType) { + existingByType.set(agentType, row); + } + } + + for (const agentType of desiredTypes) { + const existing = existingByType.get(agentType); + const agentConfig = + agentType === ownerAgentType + ? ownerAgentConfig ?? + (existing?.agent_config ? JSON.parse(existing.agent_config) : undefined) + : existing?.agent_config + ? JSON.parse(existing.agent_config) + : undefined; + const rowAddedAt = existing?.added_at ?? addedAt ?? now; + + database + .prepare( + `INSERT OR REPLACE INTO registered_groups ( + jid, + name, + folder, + trigger_pattern, + added_at, + agent_config, + requires_trigger, + is_main, + agent_type, + work_dir + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + chatJid, + snapshot.name, + snapshot.folder, + snapshot.triggerPattern, + rowAddedAt, + agentConfig ? JSON.stringify(agentConfig) : null, + snapshot.requiresTrigger ? 1 : 0, + snapshot.isMain ? 1 : 0, + agentType, + snapshot.workDir, + ); + } + + const placeholders = desiredTypes.map(() => '?').join(','); + database + .prepare( + `DELETE FROM registered_groups + WHERE jid = ? + AND agent_type NOT IN (${placeholders})`, + ) + .run(chatJid, ...desiredTypes); +} + function getStoredRoomSettingsRowFromDatabase( database: Database, chatJid: string, diff --git a/src/index.ts b/src/index.ts index c1c78ba..081ea01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,15 +24,15 @@ import { import { writeGroupsSnapshot } from './agent-runner.js'; import { listAvailableGroups } from './available-groups.js'; import { + type AssignRoomInput, + assignRoom, getAllRegisteredGroups, getAllSessions, getAllTasks, getLatestMessageSeqAtOrBefore, hasRecentRestartAnnouncement, - getRegisteredGroup, getRouterState, initDatabase, - setRegisteredGroup, setRouterState, deleteAllSessionsForGroup, deleteSession, @@ -217,29 +217,68 @@ function clearSession( } function registerGroup(jid: string, group: RegisteredGroup): void { + const assignedGroup = assignRoom(jid, { + name: group.name, + ownerAgentType: group.agentType, + folder: group.folder, + trigger: group.trigger, + requiresTrigger: group.requiresTrigger, + isMain: group.isMain, + workDir: group.workDir, + addedAt: group.added_at, + ownerAgentConfig: group.agentConfig, + }); + if (!assignedGroup) { + logger.warn({ jid }, 'Failed to assign room during legacy registration'); + return; + } + let groupDir: string; try { - groupDir = resolveGroupFolderPath(group.folder); + groupDir = resolveGroupFolderPath(assignedGroup.folder); } catch (err) { logger.warn( - { jid, folder: group.folder, err }, + { jid, folder: assignedGroup.folder, err }, 'Rejecting group registration with invalid folder', ); return; } - registeredGroups[jid] = group; - setRegisteredGroup(jid, group); + const { jid: _ignoredJid, ...storedGroup } = assignedGroup; + registeredGroups[jid] = storedGroup; // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); logger.info( - { jid, name: group.name, folder: group.folder }, + { jid, name: assignedGroup.name, folder: assignedGroup.folder }, 'Group registered', ); } +function assignRoomForIpc(jid: string, input: AssignRoomInput): void { + const assignedGroup = assignRoom(jid, input); + if (!assignedGroup) { + logger.warn({ jid }, 'Failed to assign room from IPC'); + return; + } + + let groupDir: string; + try { + groupDir = resolveGroupFolderPath(assignedGroup.folder); + } catch (err) { + logger.warn( + { jid, folder: assignedGroup.folder, err }, + 'Rejecting room assignment with invalid folder', + ); + return; + } + + const { jid: _ignoredJid, ...storedGroup } = assignedGroup; + registeredGroups[jid] = storedGroup; + fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); +} + /** * Get available groups list for the agent. * Returns groups ordered by most recent activity. @@ -483,6 +522,7 @@ async function main(): Promise { nudgeScheduler: nudgeSchedulerLoop, registeredGroups: () => registeredGroups, registerGroup, + assignRoom: assignRoomForIpc, syncGroups: async (force: boolean) => { await Promise.all( channels diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts index c6ec8ec..f8dc7a3 100644 --- a/src/ipc-auth.test.ts +++ b/src/ipc-auth.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ASSISTANT_NAME } from './config.js'; import { _initTestDatabase, + assignRoom, createTask, getAllTasks, + getRegisteredAgentTypesForJid, getRegisteredGroup, + getStoredRoomSettings, getTaskById, setRegisteredGroup, } from './db.js'; @@ -61,6 +65,12 @@ beforeEach(() => { setRegisteredGroup(jid, group); // Mock the fs.mkdirSync that registerGroup does }, + assignRoom: (jid, room) => { + const assigned = assignRoom(jid, room); + if (!assigned) return; + const { jid: _ignoredJid, ...group } = assigned; + groups[jid] = group; + }, syncGroups: async () => {}, getAvailableGroups: () => [], writeGroupsSnapshot: () => {}, @@ -794,19 +804,99 @@ describe('register_group success', () => { expect(group!.trigger).toBe('@Andy'); }); - it('register_group rejects request with missing fields', async () => { + it('register_group auto-fills missing folder and trigger for single rooms', async () => { await processTaskIpc( { type: 'register_group', jid: 'partial@g.us', name: 'Partial', - // missing folder and trigger }, 'whatsapp_main', true, deps, ); - expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); + const group = getRegisteredGroup('partial@g.us'); + expect(group).toBeDefined(); + expect(group!.folder).toMatch(/^grp_whatsapp_/); + expect(group!.trigger).toBe(`@${ASSISTANT_NAME}`); + expect(getStoredRoomSettings('partial@g.us')?.roomMode).toBe('single'); + }); + + it('register_group preserves an existing tribunal room mode', async () => { + assignRoom('legacy-tribunal@g.us', { + name: 'Legacy Tribunal', + roomMode: 'tribunal', + ownerAgentType: 'codex', + folder: 'legacy-tribunal', + }); + + await processTaskIpc( + { + type: 'register_group', + jid: 'legacy-tribunal@g.us', + name: 'Legacy Tribunal Renamed', + folder: 'legacy-tribunal', + }, + 'whatsapp_main', + true, + deps, + ); + + expect(getStoredRoomSettings('legacy-tribunal@g.us')).toMatchObject({ + chatJid: 'legacy-tribunal@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Legacy Tribunal Renamed', + }); + expect(getRegisteredAgentTypesForJid('legacy-tribunal@g.us').sort()).toEqual([ + 'claude-code', + 'codex', + ]); + }); +}); + +describe('assign_room success', () => { + it('main group can assign a tribunal room and materialize capability rows', async () => { + await processTaskIpc( + { + type: 'assign_room', + jid: 'tribunal@g.us', + name: 'Tribunal Room', + room_mode: 'tribunal', + owner_agent_type: 'codex', + }, + 'whatsapp_main', + true, + deps, + ); + + const stored = getStoredRoomSettings('tribunal@g.us'); + expect(stored).toMatchObject({ + chatJid: 'tribunal@g.us', + roomMode: 'tribunal', + modeSource: 'explicit', + name: 'Tribunal Room', + ownerAgentType: 'codex', + }); + expect(stored?.folder).toMatch(/^grp_whatsapp_/); + expect(getRegisteredAgentTypesForJid('tribunal@g.us').sort()).toEqual([ + 'claude-code', + 'codex', + ]); + }); + + it('assign_room rejects request with missing fields', async () => { + await processTaskIpc( + { + type: 'assign_room', + jid: 'broken@g.us', + }, + 'whatsapp_main', + true, + deps, + ); + + expect(getRegisteredGroup('broken@g.us')).toBeUndefined(); }); }); diff --git a/src/ipc.ts b/src/ipc.ts index 3ac2abc..c8d4605 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -7,6 +7,7 @@ import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { + type AssignRoomInput, createTask, deleteTask, findDuplicateCiWatcher, @@ -19,13 +20,14 @@ import { DEFAULT_WATCH_CI_MAX_DURATION_MS, isWatchCiTask, } from './task-watch-status.js'; -import { RegisteredGroup } from './types.js'; +import { AgentType, RegisteredGroup, RoomMode } from './types.js'; export interface IpcDeps { sendMessage: (jid: string, text: string) => Promise; nudgeScheduler?: () => void; registeredGroups: () => Record; registerGroup: (jid: string, group: RegisteredGroup) => void; + assignRoom: (jid: string, room: AssignRoomInput) => void; syncGroups: (force: boolean) => Promise; getAvailableGroups: () => AvailableGroup[]; writeGroupsSnapshot: ( @@ -292,12 +294,16 @@ export async function processTaskIpc( groupFolder?: string; chatJid?: string; targetJid?: string; - // For register_group + // For assign_room / register_group jid?: string; name?: string; folder?: string; trigger?: string; requiresTrigger?: boolean; + room_mode?: RoomMode; + owner_agent_type?: AgentType; + isMain?: boolean; + workDir?: string; agentConfig?: RegisteredGroup['agentConfig']; }, sourceGroup: string, // Verified identity from IPC directory @@ -595,36 +601,74 @@ export async function processTaskIpc( } break; + case 'assign_room': case 'register_group': - // Only main group can register new groups + // Only main group can assign/register rooms if (!isMain) { logger.warn( - { sourceGroup }, - 'Unauthorized register_group attempt blocked', + { sourceGroup, type: data.type }, + `Unauthorized ${data.type} attempt blocked`, ); break; } - if (data.jid && data.name && data.folder && data.trigger) { - if (!isValidGroupFolder(data.folder)) { + if (data.jid && data.name) { + if (data.folder && !isValidGroupFolder(data.folder)) { logger.warn( { sourceGroup, folder: data.folder }, - 'Invalid register_group request - unsafe folder name', + `Invalid ${data.type} request - unsafe folder name`, ); break; } - // Defense in depth: agent cannot set isMain via IPC - deps.registerGroup(data.jid, { + if ( + data.room_mode !== undefined && + data.room_mode !== 'single' && + data.room_mode !== 'tribunal' + ) { + logger.warn( + { sourceGroup, roomMode: data.room_mode }, + 'Invalid assign_room request - unknown room_mode', + ); + break; + } + if ( + data.owner_agent_type !== undefined && + data.owner_agent_type !== 'claude-code' && + data.owner_agent_type !== 'codex' + ) { + logger.warn( + { sourceGroup, ownerAgentType: data.owner_agent_type }, + 'Invalid assign_room request - unknown owner_agent_type', + ); + break; + } + + if (data.type === 'assign_room') { + deps.assignRoom(data.jid, { + name: data.name, + roomMode: data.room_mode, + ownerAgentType: data.owner_agent_type, + folder: data.folder, + trigger: data.trigger, + requiresTrigger: data.requiresTrigger, + isMain: data.isMain, + workDir: data.workDir, + }); + break; + } + + deps.assignRoom(data.jid, { name: data.name, + ownerAgentType: data.owner_agent_type, folder: data.folder, trigger: data.trigger, - added_at: new Date().toISOString(), - agentConfig: data.agentConfig, requiresTrigger: data.requiresTrigger, + ownerAgentConfig: data.agentConfig, + addedAt: new Date().toISOString(), }); } else { logger.warn( { data }, - 'Invalid register_group request - missing required fields', + `Invalid ${data.type} request - missing required fields`, ); } break;