diff --git a/.gitignore b/.gitignore index 2b03bf147b..8a66100201 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,6 @@ yarn-error.log* .vscode/ .VSCodeCounter .qodo + +# Claude Code local settings +.claude/settings.local.json diff --git a/src/__tests__/main/group-chat/group-chat-agent.test.ts b/src/__tests__/main/group-chat/group-chat-agent.test.ts index cecc328247..285b8eb969 100644 --- a/src/__tests__/main/group-chat/group-chat-agent.test.ts +++ b/src/__tests__/main/group-chat/group-chat-agent.test.ts @@ -47,6 +47,8 @@ import { getActiveParticipants, clearAllParticipantSessionsGlobal, getParticipantSystemPrompt, + setActiveParticipantSession, + clearActiveParticipantSession, } from '../../../main/group-chat/group-chat-agent'; import { spawnModerator, @@ -129,10 +131,10 @@ describe('group-chat-agent', () => { } // =========================================================================== - // Test 4.1: addParticipant creates session and updates chat + // Test 4.1: addParticipant creates participant record and updates chat // =========================================================================== describe('addParticipant', () => { - it('adds participant with new session', async () => { + it('adds participant with new participant record ID', async () => { const chat = await createTestChatWithModerator('Test'); const participant = await addParticipant( @@ -153,21 +155,12 @@ describe('group-chat-agent', () => { expect(updated?.participants[0].name).toBe('Client'); }); - it('spawns participant session correctly', async () => { + it('does not spawn a participant process during registration', async () => { const chat = await createTestChatWithModerator('Spawn Test'); await addParticipant(chat.id, 'Backend', 'claude-code', mockProcessManager); - // spawn is called once for participant (moderator uses batch mode, not spawn) - expect(mockProcessManager.spawn).toHaveBeenCalledTimes(1); - - // Check the participant spawn call - expect(mockProcessManager.spawn).toHaveBeenLastCalledWith( - expect.objectContaining({ - toolType: 'claude-code', - readOnlyMode: false, // Participants can make changes - }) - ); + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); }); it('throws for non-existent chat', async () => { @@ -187,23 +180,6 @@ describe('group-chat-agent', () => { expect(second.sessionId).toBe(first.sessionId); }); - it('throws when spawn fails', async () => { - // Note: spawnModerator no longer calls spawn (uses batch mode), - // so we only need to mock the participant spawn to fail - const failingProcessManager: IProcessManager = { - spawn: vi.fn().mockReturnValue({ pid: -1, success: false }), // Participant fails - write: vi.fn(), - kill: vi.fn(), - }; - - const chat = await createTestChat('Fail Test'); - await spawnModerator(chat, failingProcessManager); - - await expect( - addParticipant(chat.id, 'Client', 'claude-code', failingProcessManager) - ).rejects.toThrow(/Failed to spawn participant/); - }); - it('throws when moderator is not active', async () => { const chat = await createTestChat('No Moderator Test'); // Don't spawn moderator @@ -243,46 +219,9 @@ describe('group-chat-agent', () => { }); // =========================================================================== - // Test 4.2: addParticipant sends introduction to agent + // Test 4.2: prompt helper // =========================================================================== - describe('addParticipant - introduction', () => { - it('sends introduction to new participant', async () => { - const chat = await createTestChatWithModerator('Intro Test'); - - await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); - - // Check that spawn was called with a prompt containing the role - expect(mockProcessManager.spawn).toHaveBeenLastCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining('Your Role: Client'), - }) - ); - }); - - it('includes chat name in introduction', async () => { - const chat = await createTestChatWithModerator('My Project Chat'); - - await addParticipant(chat.id, 'Developer', 'claude-code', mockProcessManager); - - expect(mockProcessManager.spawn).toHaveBeenLastCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining('My Project Chat'), - }) - ); - }); - - it('includes log path in introduction', async () => { - const chat = await createTestChatWithModerator('Log Path Test'); - - await addParticipant(chat.id, 'Analyst', 'claude-code', mockProcessManager); - - expect(mockProcessManager.spawn).toHaveBeenLastCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining('chat.log'), - }) - ); - }); - + describe('getParticipantSystemPrompt', () => { it('getParticipantSystemPrompt generates correct prompt', () => { const prompt = getParticipantSystemPrompt('Tester', 'QA Chat', '/path/to/chat.log'); @@ -299,41 +238,31 @@ describe('group-chat-agent', () => { describe('sendToParticipant', () => { it('sends message to participant session', async () => { const chat = await createTestChatWithModerator('Send Test'); - const participant = await addParticipant( - chat.id, - 'Client', - 'claude-code', - mockProcessManager - ); + await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-123'); await sendToParticipant(chat.id, 'Client', 'Please implement auth', mockProcessManager); expect(mockProcessManager.write).toHaveBeenCalledWith( - participant.sessionId, + 'active-session-123', expect.stringContaining('Please implement auth') ); }); it('appends newline to message', async () => { const chat = await createTestChatWithModerator('Newline Test'); - const participant = await addParticipant( - chat.id, - 'Client', - 'claude-code', - mockProcessManager - ); + await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-456'); await sendToParticipant(chat.id, 'Client', 'Task message', mockProcessManager); - expect(mockProcessManager.write).toHaveBeenCalledWith( - participant.sessionId, - 'Task message\n' - ); + expect(mockProcessManager.write).toHaveBeenCalledWith('active-session-456', 'Task message\n'); }); it('logs message to chat log', async () => { const chat = await createTestChatWithModerator('Log Test'); await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-log'); await sendToParticipant(chat.id, 'Client', 'Logged message', mockProcessManager); @@ -381,16 +310,12 @@ describe('group-chat-agent', () => { describe('removeParticipant', () => { it('removes participant and kills session', async () => { const chat = await createTestChatWithModerator('Remove Test'); - const participant = await addParticipant( - chat.id, - 'Client', - 'claude-code', - mockProcessManager - ); + await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-789'); await removeParticipant(chat.id, 'Client', mockProcessManager); - expect(mockProcessManager.kill).toHaveBeenCalledWith(participant.sessionId); + expect(mockProcessManager.kill).toHaveBeenCalledWith('active-session-789'); const updated = await loadGroupChat(chat.id); expect(updated?.participants).toHaveLength(0); @@ -399,6 +324,7 @@ describe('group-chat-agent', () => { it('removes from active sessions', async () => { const chat = await createTestChatWithModerator('Active Test'); await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-999'); expect(isParticipantActive(chat.id, 'Client')).toBe(true); @@ -439,14 +365,10 @@ describe('group-chat-agent', () => { describe('helper functions', () => { it('getParticipantSessionId returns correct ID', async () => { const chat = await createTestChatWithModerator('Get Session Test'); - const participant = await addParticipant( - chat.id, - 'Client', - 'claude-code', - mockProcessManager - ); + await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-321'); - expect(getParticipantSessionId(chat.id, 'Client')).toBe(participant.sessionId); + expect(getParticipantSessionId(chat.id, 'Client')).toBe('active-session-321'); }); it('getParticipantSessionId returns undefined for unknown', () => { @@ -459,6 +381,7 @@ describe('group-chat-agent', () => { expect(isParticipantActive(chat.id, 'Client')).toBe(false); await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-654'); expect(isParticipantActive(chat.id, 'Client')).toBe(true); }); @@ -468,6 +391,8 @@ describe('group-chat-agent', () => { await addParticipant(chat.id, 'Frontend', 'claude-code', mockProcessManager); await addParticipant(chat.id, 'Backend', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Frontend', 'active-frontend'); + setActiveParticipantSession(chat.id, 'Backend', 'active-backend'); const active = getActiveParticipants(chat.id); expect(active).toContain('Frontend'); @@ -485,6 +410,8 @@ describe('group-chat-agent', () => { await addParticipant(chat1.id, 'Client1', 'claude-code', mockProcessManager); await addParticipant(chat2.id, 'Client2', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat1.id, 'Client1', 'global-client-1'); + setActiveParticipantSession(chat2.id, 'Client2', 'global-client-2'); clearAllParticipantSessionsGlobal(); @@ -503,6 +430,8 @@ describe('group-chat-agent', () => { await addParticipant(chat1.id, 'Client', 'claude-code', mockProcessManager); await addParticipant(chat2.id, 'Client', 'opencode', mockProcessManager); + setActiveParticipantSession(chat1.id, 'Client', 'chat1-session'); + setActiveParticipantSession(chat2.id, 'Client', 'chat2-session'); // Same name but different chats - both should work expect(isParticipantActive(chat1.id, 'Client')).toBe(true); @@ -525,5 +454,15 @@ describe('group-chat-agent', () => { expect(updated?.participants[0].agentId).toBe('claude-code'); expect(updated?.participants[1].agentId).toBe('opencode'); }); + + it('can clear a specific active participant session', async () => { + const chat = await createTestChatWithModerator('Clear Active Test'); + await addParticipant(chat.id, 'Client', 'claude-code', mockProcessManager); + setActiveParticipantSession(chat.id, 'Client', 'active-session-clear'); + + clearActiveParticipantSession(chat.id, 'Client'); + + expect(isParticipantActive(chat.id, 'Client')).toBe(false); + }); }); }); diff --git a/src/__tests__/main/group-chat/group-chat-router.test.ts b/src/__tests__/main/group-chat/group-chat-router.test.ts index e8a0e82b6e..cb949c28bb 100644 --- a/src/__tests__/main/group-chat/group-chat-router.test.ts +++ b/src/__tests__/main/group-chat/group-chat-router.test.ts @@ -48,6 +48,8 @@ vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ import { extractMentions, + extractAllMentions, + extractAutoRunDirectives, routeUserMessage, routeModeratorResponse, routeAgentResponse, @@ -73,6 +75,7 @@ import { } from '../../../main/group-chat/group-chat-storage'; import { readLog } from '../../../main/group-chat/group-chat-log'; import { AgentDetector } from '../../../main/agents'; +import { groupChatEmitters } from '../../../main/ipc/handlers/groupChat'; describe('group-chat-router', () => { let mockProcessManager: IProcessManager; @@ -147,6 +150,7 @@ describe('group-chat-router', () => { // Clear mocks vi.clearAllMocks(); + groupChatEmitters.emitMessage = undefined; }); // Helper to track created chats for cleanup @@ -315,6 +319,99 @@ describe('group-chat-router', () => { }); }); + // =========================================================================== + // Test 5.2b: Markdown-formatted mentions + // AI moderators often wrap mentions in bold/italic/code markdown. + // =========================================================================== + describe('extractMentions - markdown formatting', () => { + const participants: GroupChatParticipant[] = [ + { name: 'controlplane', agentId: 'claude-code', sessionId: '1', addedAt: 0 }, + { name: 'dataplane', agentId: 'claude-code', sessionId: '2', addedAt: 0 }, + { name: 'Client', agentId: 'claude-code', sessionId: '3', addedAt: 0 }, + ]; + + it('handles bold markdown **@name**', () => { + const mentions = extractMentions( + '**@controlplane** — Please execute your plan.', + participants + ); + expect(mentions).toEqual(['controlplane']); + }); + + it('handles italic markdown _@name_', () => { + const mentions = extractMentions('_@Client_ should review this', participants); + expect(mentions).toEqual(['Client']); + }); + + it('handles bold+italic markdown ***@name***', () => { + const mentions = extractMentions('***@dataplane*** is ready', participants); + expect(mentions).toEqual(['dataplane']); + }); + + it('handles backtick markdown `@name`', () => { + const mentions = extractMentions('`@controlplane` run the task', participants); + expect(mentions).toEqual(['controlplane']); + }); + + it('handles strikethrough markdown ~~@name~~', () => { + const mentions = extractMentions('~~@Client~~ was reassigned', participants); + expect(mentions).toEqual(['Client']); + }); + + it('handles multiple markdown-formatted mentions in one message', () => { + const mentions = extractMentions( + '- **@controlplane** — execute plan\n- **@dataplane** — verify results', + participants + ); + expect(mentions).toEqual(['controlplane', 'dataplane']); + }); + + it('handles mixed formatted and plain mentions', () => { + const mentions = extractMentions( + '**@controlplane** and @dataplane should coordinate', + participants + ); + expect(mentions).toEqual(['controlplane', 'dataplane']); + }); + }); + + // =========================================================================== + // Test 5.2c: extractAllMentions with markdown formatting + // =========================================================================== + describe('extractAllMentions - markdown formatting', () => { + it('strips markdown from extracted mention names', () => { + const mentions = extractAllMentions('**@controlplane** and _@dataplane_'); + expect(mentions).toEqual(['controlplane', 'dataplane']); + }); + + it('handles backtick-wrapped mentions', () => { + const mentions = extractAllMentions('`@myAgent` should handle this'); + expect(mentions).toEqual(['myAgent']); + }); + + it('does not produce empty mentions from bare @**', () => { + const mentions = extractAllMentions('@** is not a real mention'); + expect(mentions).toEqual([]); + }); + }); + + // =========================================================================== + // Test 5.2d: extractAutoRunDirectives with markdown formatting + // =========================================================================== + describe('extractAutoRunDirectives - markdown formatting', () => { + it('strips markdown from autorun directive participant names', () => { + const result = extractAutoRunDirectives('!autorun @**controlplane**'); + expect(result.autoRunParticipants).toEqual(['controlplane']); + }); + + it('handles autorun with filename and markdown', () => { + const result = extractAutoRunDirectives('!autorun @*controlplane*:plan.md'); + expect(result.autoRunDirectives).toEqual([ + { participantName: 'controlplane', filename: 'plan.md' }, + ]); + }); + }); + // =========================================================================== // Test 5.3: routeUserMessage spawns moderator process in batch mode // Note: routeUserMessage now spawns a batch process per message instead of @@ -458,6 +555,29 @@ describe('group-chat-router', () => { expect(mockProcessManager.spawn).not.toHaveBeenCalled(); }); + it('treats unresolved @tokens as plain text without emitting a system warning', async () => { + const chat = await createTestChatWithModerator('Literal At Symbol Test'); + const emitMessage = vi.fn(); + groupChatEmitters.emitMessage = emitMessage; + + mockProcessManager.spawn.mockClear(); + + await routeModeratorResponse( + chat.id, + 'Please keep the literal @example value in the final message.', + mockProcessManager, + mockAgentDetector + ); + + expect(mockProcessManager.spawn).not.toHaveBeenCalled(); + expect(emitMessage).not.toHaveBeenCalledWith( + chat.id, + expect.objectContaining({ + from: 'system', + }) + ); + }); + it('throws for non-existent chat', async () => { await expect( routeModeratorResponse('non-existent-id', 'Hello', mockProcessManager) @@ -699,6 +819,30 @@ describe('group-chat-router', () => { expect(participantSpawnCall).toBeDefined(); expect(participantSpawnCall?.[0].readOnlyMode).toBe(false); }); + + it('auto-added participants are only started once for a moderator handoff', async () => { + const chat = await createTestChatWithModerator('Auto Add Single Spawn Test'); + setGetSessionsCallback(() => [ + { + id: 'session-client', + name: 'Client', + toolType: 'claude-code', + cwd: '/tmp/project', + }, + ]); + + await routeModeratorResponse( + chat.id, + '@Client: Please create the requested file', + mockProcessManager, + mockAgentDetector + ); + + const participantSpawns = mockProcessManager.spawn.mock.calls.filter((call) => + call[0].sessionId?.includes(`group-chat-${chat.id}-participant-Client-`) + ); + expect(participantSpawns).toHaveLength(1); + }); }); // =========================================================================== @@ -797,7 +941,7 @@ describe('group-chat-router', () => { mockWrapSpawnWithSsh.mockReset(); }); - it('user-mention auto-add passes sshRemoteConfig and sshStore to addParticipant', async () => { + it('user-mention auto-add stores SSH participant metadata without spawning yet', async () => { const chat = await createTestChatWithModerator('SSH User Mention Test'); // Set up a session with SSH config that the router can discover @@ -820,13 +964,15 @@ describe('group-chat-router', () => { mockAgentDetector ); - // The SSH wrapper should have been called when addParticipant spawned the agent - expect(mockWrapSpawnWithSsh).toHaveBeenCalledWith( - expect.objectContaining({ - command: expect.any(String), - }), - sshRemoteConfig, - mockSshStore + expect(mockWrapSpawnWithSsh).not.toHaveBeenCalled(); + const updatedChat = await loadGroupChat(chat.id); + expect(updatedChat?.participants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'RemoteAgent', + sshRemoteName: 'PedTome', + }), + ]) ); }); diff --git a/src/__tests__/main/ipc/handlers/groupChat.test.ts b/src/__tests__/main/ipc/handlers/groupChat.test.ts index 4d3fffc46d..73f3e222c2 100644 --- a/src/__tests__/main/ipc/handlers/groupChat.test.ts +++ b/src/__tests__/main/ipc/handlers/groupChat.test.ts @@ -70,6 +70,10 @@ vi.mock('../../../../main/group-chat/group-chat-agent', () => ({ // Mock group-chat-router vi.mock('../../../../main/group-chat/group-chat-router', () => ({ routeUserMessage: vi.fn(), + clearPendingParticipants: vi.fn(), + routeAgentResponse: vi.fn(), + markParticipantResponded: vi.fn(), + spawnModeratorSynthesis: vi.fn(), })); // Mock agent-detector @@ -169,6 +173,8 @@ describe('groupChat IPC handlers', () => { 'groupChat:startModerator', 'groupChat:sendToModerator', 'groupChat:stopModerator', + 'groupChat:stopAll', + 'groupChat:reportAutoRunComplete', 'groupChat:getModeratorSessionId', // Participant handlers 'groupChat:addParticipant', @@ -987,6 +993,107 @@ describe('groupChat IPC handlers', () => { }); }); + describe('groupChat:stopAll', () => { + it('should kill moderator, clear participant sessions, and emit idle states', async () => { + const mockChat: GroupChat = { + id: 'gc-stop-all', + name: 'Stop All Chat', + createdAt: Date.now(), + updatedAt: Date.now(), + moderatorAgentId: 'claude-code', + moderatorSessionId: 'session-stop', + participants: [ + { + name: 'Worker 1', + agentId: 'claude-code', + sessionId: 'p-1', + addedAt: Date.now(), + }, + { + name: 'Worker 2', + agentId: 'claude-code', + sessionId: 'p-2', + addedAt: Date.now(), + }, + ], + logPath: '/path/stop', + imagesDir: '/images/stop', + }; + + vi.mocked(groupChatModerator.killModerator).mockResolvedValue(undefined); + vi.mocked(groupChatAgent.clearAllParticipantSessions).mockResolvedValue(undefined); + vi.mocked(groupChatStorage.loadGroupChat).mockResolvedValue(mockChat); + + const handler = handlers.get('groupChat:stopAll'); + await handler!({} as any, 'gc-stop-all'); + + expect(groupChatModerator.killModerator).toHaveBeenCalledWith( + 'gc-stop-all', + mockProcessManager + ); + expect(groupChatAgent.clearAllParticipantSessions).toHaveBeenCalledWith( + 'gc-stop-all', + mockProcessManager + ); + expect(groupChatRouter.clearPendingParticipants).toHaveBeenCalledWith('gc-stop-all'); + }); + + it('should handle null process manager', async () => { + const depsNoProcessManager: GroupChatHandlerDependencies = { + ...mockDeps, + getProcessManager: () => null, + }; + + handlers.clear(); + registerGroupChatHandlers(depsNoProcessManager); + + vi.mocked(groupChatModerator.killModerator).mockResolvedValue(undefined); + vi.mocked(groupChatAgent.clearAllParticipantSessions).mockResolvedValue(undefined); + vi.mocked(groupChatStorage.loadGroupChat).mockResolvedValue(null); + + const handler = handlers.get('groupChat:stopAll'); + await handler!({} as any, 'gc-stop-null'); + + expect(groupChatModerator.killModerator).toHaveBeenCalledWith('gc-stop-null', undefined); + expect(groupChatAgent.clearAllParticipantSessions).toHaveBeenCalledWith( + 'gc-stop-null', + undefined + ); + }); + }); + + describe('groupChat:reportAutoRunComplete', () => { + it('should route agent response and mark participant as responded', async () => { + vi.mocked(groupChatRouter.routeAgentResponse).mockResolvedValue(undefined); + vi.mocked(groupChatRouter.markParticipantResponded).mockReturnValue(false); + + const handler = handlers.get('groupChat:reportAutoRunComplete'); + await handler!({} as any, 'gc-autorun', 'Worker 1', 'Task completed successfully'); + + expect(groupChatRouter.routeAgentResponse).toHaveBeenCalledWith( + 'gc-autorun', + 'Worker 1', + 'Task completed successfully', + mockProcessManager + ); + }); + + it('should trigger synthesis when all participants have responded', async () => { + vi.mocked(groupChatRouter.routeAgentResponse).mockResolvedValue(undefined); + vi.mocked(groupChatRouter.markParticipantResponded).mockReturnValue(true); + vi.mocked(groupChatRouter.spawnModeratorSynthesis).mockResolvedValue(undefined); + + const handler = handlers.get('groupChat:reportAutoRunComplete'); + await handler!({} as any, 'gc-autorun-done', 'Worker 1', 'All done'); + + expect(groupChatRouter.routeAgentResponse).toHaveBeenCalled(); + expect(groupChatRouter.markParticipantResponded).toHaveBeenCalledWith( + 'gc-autorun-done', + 'Worker 1' + ); + }); + }); + describe('event emitters', () => { it('should set up emitMessage emitter', () => { expect(groupChatEmitters.emitMessage).toBeDefined(); diff --git a/src/__tests__/main/process-listeners/exit-listener.test.ts b/src/__tests__/main/process-listeners/exit-listener.test.ts index 7988edeeba..e987aaab8a 100644 --- a/src/__tests__/main/process-listeners/exit-listener.test.ts +++ b/src/__tests__/main/process-listeners/exit-listener.test.ts @@ -54,6 +54,7 @@ describe('Exit Listener', () => { emitParticipantState: vi.fn(), emitParticipantsChanged: vi.fn(), emitModeratorUsage: vi.fn(), + emitMessage: vi.fn(), }, groupChatRouter: { routeModeratorResponse: vi.fn().mockResolvedValue(undefined), @@ -62,6 +63,7 @@ describe('Exit Listener', () => { spawnModeratorSynthesis: vi.fn().mockResolvedValue(undefined), getGroupChatReadOnlyState: vi.fn().mockReturnValue(false), respawnParticipantWithRecovery: vi.fn().mockResolvedValue(undefined), + clearActiveParticipantTaskSession: vi.fn(), }, groupChatStorage: { loadGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), diff --git a/src/__tests__/renderer/components/GroupChatHeader.test.tsx b/src/__tests__/renderer/components/GroupChatHeader.test.tsx index 74ac05bba6..70111b81d5 100644 --- a/src/__tests__/renderer/components/GroupChatHeader.test.tsx +++ b/src/__tests__/renderer/components/GroupChatHeader.test.tsx @@ -24,6 +24,11 @@ vi.mock('lucide-react', () => ({ $ ), + StopCircle: ({ className }: { className?: string }) => ( + + ⏹ + + ), })); const mockTheme = { @@ -44,6 +49,8 @@ const defaultProps = { theme: mockTheme, name: 'Test Chat', participantCount: 3, + state: 'idle' as const, + onStopAll: vi.fn(), onRename: vi.fn(), onShowInfo: vi.fn(), rightPanelOpen: false, @@ -97,4 +104,23 @@ describe('GroupChatHeader', () => { render(); expect(screen.getByText('1 participant')).toBeTruthy(); }); + + it('shows Stop All button when state is not idle', () => { + render(); + expect(screen.getByText('Stop All')).toBeTruthy(); + }); + + it('hides Stop All button when state is idle', () => { + render(); + expect(screen.queryByText('Stop All')).toBeNull(); + }); + + it('calls onStopAll when Stop All button is clicked', () => { + const onStopAll = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Stop All')); + expect(onStopAll).toHaveBeenCalledOnce(); + }); }); diff --git a/src/__tests__/renderer/utils/groupChatAutoRun.test.ts b/src/__tests__/renderer/utils/groupChatAutoRun.test.ts new file mode 100644 index 0000000000..8c9b559b92 --- /dev/null +++ b/src/__tests__/renderer/utils/groupChatAutoRun.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + normalizeAutoRunTargetFilename, + resolveGroupChatAutoRunTarget, +} from '../../../renderer/utils/groupChatAutoRun'; + +describe('groupChatAutoRun', () => { + describe('normalizeAutoRunTargetFilename', () => { + it('removes md extension and normalizes slashes', () => { + expect(normalizeAutoRunTargetFilename('./plans\\phase-01.md')).toBe('plans/phase-01'); + }); + }); + + describe('resolveGroupChatAutoRunTarget', () => { + it('matches an exact relative path after normalization', () => { + expect( + resolveGroupChatAutoRunTarget(['plans/phase-01', 'plans/phase-02'], 'plans/phase-01.md') + ).toEqual({ + files: ['plans/phase-01'], + }); + }); + + it('matches a unique basename when the moderator omits the subfolder', () => { + expect( + resolveGroupChatAutoRunTarget(['plans/phase-01', 'plans/phase-02'], 'phase-02.md') + ).toEqual({ + files: ['plans/phase-02'], + }); + }); + + it('returns an ambiguity error for duplicate basenames', () => { + expect(resolveGroupChatAutoRunTarget(['frontend/plan', 'backend/plan'], 'plan.md')).toEqual({ + error: 'Specified file "plan.md" is ambiguous. Matching files: frontend/plan, backend/plan', + }); + }); + + it('returns a not found error for unknown files', () => { + expect(resolveGroupChatAutoRunTarget(['plans/phase-01'], 'missing.md')).toEqual({ + error: 'Specified file "missing.md" not found. Available files: plans/phase-01', + }); + }); + }); +}); diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index ac9ed97262..9ad495741a 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -6,9 +6,11 @@ * - Each participant has a unique name within the chat * - Participants receive messages from the moderator * - Participants can collaborate by referencing the shared chat log + * + * Participants are registered up front, but their actual work runs in + * one-shot task processes spawned by the router for each moderator handoff. */ -import * as os from 'os'; import { v4 as uuidv4 } from 'uuid'; import { GroupChatParticipant, @@ -19,20 +21,11 @@ import { } from './group-chat-storage'; import { appendToLog } from './group-chat-log'; import { IProcessManager, isModeratorActive } from './group-chat-moderator'; -import type { AgentDetector } from '../agents'; -import { - buildAgentArgs, - applyAgentConfigOverrides, - getContextWindowValue, -} from '../utils/agent-args'; import { groupChatParticipantPrompt } from '../../prompts'; -import { wrapSpawnWithSsh } from '../utils/ssh-spawn-wrapper'; -import type { SshRemoteSettingsStore } from '../utils/ssh-remote-resolver'; -import { getWindowsSpawnConfig } from './group-chat-config'; /** * In-memory store for active participant sessions. - * Maps `${groupChatId}:${participantName}` -> sessionId + * Maps `${groupChatId}:${participantName}` -> currently running task sessionId */ const activeParticipantSessions = new Map(); @@ -76,37 +69,30 @@ export interface SessionOverrides { } /** - * Adds a participant to a group chat and spawns their agent session. + * Adds a participant to a group chat. * * @param groupChatId - The ID of the group chat * @param name - The participant's name (must be unique within the chat) * @param agentId - The agent type to use (e.g., 'claude-code') - * @param processManager - The process manager to use for spawning - * @param cwd - Working directory for the agent (defaults to home directory) - * @param agentDetector - Optional agent detector for resolving agent paths - * @param agentConfigValues - Optional agent config values (from config store) - * @param customEnvVars - Optional custom environment variables for the agent (deprecated, use sessionOverrides) - * @param sessionOverrides - Optional session-specific overrides (customModel, customArgs, customEnvVars, sshRemoteConfig) - * @param sshStore - Optional SSH settings store for remote execution support + * @param processManager - Unused, kept for API compatibility with existing call sites * @returns The created participant */ export async function addParticipant( groupChatId: string, name: string, agentId: string, - processManager: IProcessManager, - cwd: string = os.homedir(), - agentDetector?: AgentDetector, - agentConfigValues?: Record, - customEnvVars?: Record, + _processManager: IProcessManager, + _cwd?: string, + _agentDetector?: unknown, + _agentConfigValues?: Record, + _customEnvVars?: Record, sessionOverrides?: SessionOverrides, - sshStore?: SshRemoteSettingsStore + _sshStore?: unknown ): Promise { console.log(`[GroupChat:Debug] ========== ADD PARTICIPANT ==========`); console.log(`[GroupChat:Debug] Group Chat ID: ${groupChatId}`); console.log(`[GroupChat:Debug] Participant Name: ${name}`); console.log(`[GroupChat:Debug] Agent ID: ${agentId}`); - console.log(`[GroupChat:Debug] CWD: ${cwd}`); const chat = await loadGroupChat(groupChatId); if (!chat) { @@ -133,121 +119,10 @@ export async function addParticipant( return existingParticipant; } - // Resolve the agent configuration to get the executable command - let command = agentId; - let args: string[] = []; - let agentConfig: Awaited> | null = null; - - if (agentDetector) { - agentConfig = await agentDetector.getAgent(agentId); - console.log( - `[GroupChat:Debug] Agent resolved: ${agentConfig?.command || 'null'}, available: ${agentConfig?.available ?? false}` - ); - if (!agentConfig || !agentConfig.available) { - console.log(`[GroupChat:Debug] ERROR: Agent not available!`); - throw new Error(`Agent '${agentId}' is not available`); - } - command = agentConfig.path || agentConfig.command; - args = [...agentConfig.args]; - } - - const prompt = getParticipantSystemPrompt(name, chat.name, chat.logPath); - // Note: Don't pass modelId to buildAgentArgs - it will be handled by applyAgentConfigOverrides - // via sessionCustomModel to avoid duplicate --model args - const baseArgs = buildAgentArgs(agentConfig, { - baseArgs: args, - prompt, - cwd, - readOnlyMode: false, - }); - // Merge customEnvVars with sessionOverrides.customEnvVars (sessionOverrides takes precedence) - const effectiveEnvVars = sessionOverrides?.customEnvVars ?? customEnvVars; - const configResolution = applyAgentConfigOverrides(agentConfig, baseArgs, { - agentConfigValues: agentConfigValues || {}, - sessionCustomModel: sessionOverrides?.customModel, - sessionCustomArgs: sessionOverrides?.customArgs, - sessionCustomEnvVars: effectiveEnvVars, - }); - - console.log(`[GroupChat:Debug] Command: ${command}`); - console.log(`[GroupChat:Debug] Args: ${JSON.stringify(configResolution.args)}`); - - // Generate session ID for this participant + // Generate a stable participant record ID. Actual task runs use separate + // batch session IDs created by the router per moderator handoff. const sessionId = `group-chat-${groupChatId}-participant-${name}-${uuidv4()}`; - console.log(`[GroupChat:Debug] Generated session ID: ${sessionId}`); - - // Wrap spawn config with SSH if configured - let spawnCommand = command; - let spawnArgs = configResolution.args; - let spawnCwd = cwd; - let spawnPrompt: string | undefined = prompt; - let spawnEnvVars = configResolution.effectiveCustomEnvVars ?? effectiveEnvVars; - let spawnShell: string | undefined; - let spawnRunInShell = false; - - // Apply SSH wrapping if SSH is configured and store is available - if (sshStore && sessionOverrides?.sshRemoteConfig) { - console.log(`[GroupChat:Debug] Applying SSH wrapping for participant...`); - const sshWrapped = await wrapSpawnWithSsh( - { - command, - args: configResolution.args, - cwd, - prompt, - customEnvVars: configResolution.effectiveCustomEnvVars ?? effectiveEnvVars, - promptArgs: agentConfig?.promptArgs, - noPromptSeparator: agentConfig?.noPromptSeparator, - agentBinaryName: agentConfig?.binaryName, - }, - sessionOverrides.sshRemoteConfig, - sshStore - ); - spawnCommand = sshWrapped.command; - spawnArgs = sshWrapped.args; - spawnCwd = sshWrapped.cwd; - spawnPrompt = sshWrapped.prompt; - spawnEnvVars = sshWrapped.customEnvVars; - if (sshWrapped.sshRemoteUsed) { - console.log(`[GroupChat:Debug] SSH remote used: ${sshWrapped.sshRemoteUsed.name}`); - } - } - - // Get Windows-specific spawn config (shell, stdin mode) - handles SSH exclusion - const winConfig = getWindowsSpawnConfig(agentId, sessionOverrides?.sshRemoteConfig); - if (winConfig.shell) { - spawnShell = winConfig.shell; - spawnRunInShell = winConfig.runInShell; - console.log(`[GroupChat:Debug] Windows shell config for participant: ${winConfig.shell}`); - } - - // Spawn the participant agent - console.log(`[GroupChat:Debug] Spawning participant agent...`); - const result = processManager.spawn({ - sessionId, - toolType: agentId, - cwd: spawnCwd, - command: spawnCommand, - args: spawnArgs, - readOnlyMode: false, // Participants can make changes - prompt: spawnPrompt, - contextWindow: getContextWindowValue(agentConfig, agentConfigValues || {}), - customEnvVars: spawnEnvVars, - promptArgs: agentConfig?.promptArgs, - noPromptSeparator: agentConfig?.noPromptSeparator, - shell: spawnShell, - runInShell: spawnRunInShell, - sendPromptViaStdin: winConfig.sendPromptViaStdin, - sendPromptViaStdinRaw: winConfig.sendPromptViaStdinRaw, - }); - - console.log(`[GroupChat:Debug] Spawn result: ${JSON.stringify(result)}`); - console.log(`[GroupChat:Debug] promptArgs: ${agentConfig?.promptArgs ? 'defined' : 'undefined'}`); - console.log(`[GroupChat:Debug] noPromptSeparator: ${agentConfig?.noPromptSeparator ?? false}`); - - if (!result.success) { - console.log(`[GroupChat:Debug] ERROR: Spawn failed!`); - throw new Error(`Failed to spawn participant '${name}' for group chat ${groupChatId}`); - } + console.log(`[GroupChat:Debug] Generated participant record ID: ${sessionId}`); // Create participant record const participant: GroupChatParticipant = { @@ -258,10 +133,6 @@ export async function addParticipant( sshRemoteName: sessionOverrides?.sshRemoteName, }; - // Store the session mapping - activeParticipantSessions.set(getParticipantKey(groupChatId, name), sessionId); - console.log(`[GroupChat:Debug] Session stored in active map`); - // Add participant to the group chat await addParticipantToChat(groupChatId, participant); console.log(`[GroupChat:Debug] Participant added to chat storage`); @@ -270,6 +141,24 @@ export async function addParticipant( return participant; } +/** + * Tracks the currently running task session for a participant. + */ +export function setActiveParticipantSession( + groupChatId: string, + participantName: string, + sessionId: string +): void { + activeParticipantSessions.set(getParticipantKey(groupChatId, participantName), sessionId); +} + +/** + * Clears the currently running task session for a participant. + */ +export function clearActiveParticipantSession(groupChatId: string, participantName: string): void { + activeParticipantSessions.delete(getParticipantKey(groupChatId, participantName)); +} + /** * Sends a message to a specific participant in a group chat. * diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index 1ac924f6ee..fb7c5db2c7 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -30,7 +30,11 @@ import { getModeratorSystemPrompt, getModeratorSynthesisPrompt, } from './group-chat-moderator'; -import { addParticipant } from './group-chat-agent'; +import { + addParticipant, + setActiveParticipantSession, + clearActiveParticipantSession, +} from './group-chat-agent'; import { AgentDetector } from '../agents'; import { powerManager } from '../power-manager'; import { logger } from '../utils/logger'; @@ -72,6 +76,8 @@ export interface SessionInfo { remoteId: string | null; workingDirOverride?: string; }; + /** Auto Run folder path for this session */ + autoRunFolderPath?: string; } /** @@ -84,6 +90,10 @@ export type GetSessionsCallback = () => SessionInfo[]; */ export type GetCustomEnvVarsCallback = (agentId: string) => Record | undefined; export type GetAgentConfigCallback = (agentId: string) => Record | undefined; +export type GetModeratorSettingsCallback = () => { + standingInstructions: string; + conductorProfile: string; +}; // Module-level callback for session lookup let getSessionsCallback: GetSessionsCallback | null = null; @@ -92,6 +102,9 @@ let getSessionsCallback: GetSessionsCallback | null = null; let getCustomEnvVarsCallback: GetCustomEnvVarsCallback | null = null; let getAgentConfigCallback: GetAgentConfigCallback | null = null; +// Module-level callback for moderator settings (standing instructions + conductor profile) +let getModeratorSettingsCallback: GetModeratorSettingsCallback | null = null; + // Module-level SSH store for remote execution support let sshStore: SshRemoteSettingsStore | null = null; @@ -102,6 +115,130 @@ let sshStore: SshRemoteSettingsStore | null = null; */ const pendingParticipantResponses = new Map>(); +/** + * Tracks which participants in each group chat were triggered via !autorun directives. + * Used to gate emitAutoRunBatchComplete so it only fires for autorun participants, + * not for normal @mention participants sharing the same timeout path. + * Maps groupChatId -> Set + */ +const autoRunParticipantTracker = new Map>(); + +/** + * Tracks per-participant response timeout handles. + * Maps `${groupChatId}:${participantName}` -> NodeJS.Timeout + * Timeouts fire if a participant never responds (hung process, lost IPC, etc.) + */ +const participantTimeouts = new Map>(); + +/** How long to wait for a participant before treating them as timed-out (10 minutes). */ +const PARTICIPANT_RESPONSE_TIMEOUT_MS = 10 * 60 * 1000; + +function getParticipantTimeoutKey(groupChatId: string, participantName: string): string { + return `${groupChatId}:${participantName}`; +} + +/** + * Registers a response timeout for a participant. + * If the participant doesn't respond in PARTICIPANT_RESPONSE_TIMEOUT_MS, they are + * force-marked as responded so synthesis can proceed and the chat doesn't hang forever. + */ +function setParticipantResponseTimeout( + groupChatId: string, + participantName: string, + processManager: IProcessManager | undefined, + agentDetector: AgentDetector | undefined +): void { + const key = getParticipantTimeoutKey(groupChatId, participantName); + // Clear any existing timeout for this participant + const existing = participantTimeouts.get(key); + if (existing) clearTimeout(existing); + + const handle = setTimeout(async () => { + participantTimeouts.delete(key); + const pending = pendingParticipantResponses.get(groupChatId); + if (!pending?.has(participantName)) return; // Already responded + + console.warn( + `[GroupChat:Debug] Participant ${participantName} timed out after ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 1000}s — force-completing` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ @${participantName} did not respond within ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 60000} minutes and has been marked as timed out.`, + }); + + // Log a timeout response so the moderator knows what happened + try { + const { loadGroupChat } = await import('./group-chat-storage'); + const { appendToLog } = await import('./group-chat-log'); + const chat = await loadGroupChat(groupChatId); + if (chat) { + await appendToLog( + chat.logPath, + participantName, + `[Timed out — no response after ${PARTICIPANT_RESPONSE_TIMEOUT_MS / 60000} minutes]` + ); + } + } catch (err) { + // Non-critical — synthesize anyway, but log and report so we can diagnose + logger.error('Failed to log timeout response', LOG_CONTEXT, { + groupChatId, + participantName, + error: err, + }); + captureException(err, { + operation: 'groupChat:logTimeoutResponse', + groupChatId, + participantName, + }); + } + + // Reset participant state and force-complete the batch so the AUTO badge + // and progress bar clear immediately — the batch loop may still be awaiting + // a process exit that will never come. + groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + // Only emit batch-complete for participants triggered via !autorun, not normal @mentions + const autoRunSet = autoRunParticipantTracker.get(groupChatId); + if (autoRunSet?.has(participantName)) { + groupChatEmitters.emitAutoRunBatchComplete?.(groupChatId, participantName); + autoRunSet.delete(participantName); + if (autoRunSet.size === 0) autoRunParticipantTracker.delete(groupChatId); + } + + const isLast = markParticipantResponded(groupChatId, participantName); + if (isLast && processManager && agentDetector) { + spawnModeratorSynthesis(groupChatId, processManager, agentDetector).catch((err) => { + logger.error('Failed to spawn moderator synthesis after participant timeout', LOG_CONTEXT, { + error: err, + groupChatId, + participantName, + }); + captureException(err, { + operation: 'groupChat:spawnSynthesisAfterTimeout', + groupChatId, + participantName, + }); + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); + powerManager.removeBlockReason(`groupchat:${groupChatId}`); + }); + } + }, PARTICIPANT_RESPONSE_TIMEOUT_MS); + + participantTimeouts.set(key, handle); +} + +/** + * Cancels the response timeout for a participant (called when they do respond). + */ +function clearParticipantResponseTimeout(groupChatId: string, participantName: string): void { + const key = getParticipantTimeoutKey(groupChatId, participantName); + const handle = participantTimeouts.get(key); + if (handle) { + clearTimeout(handle); + participantTimeouts.delete(key); + } +} + /** * Tracks read-only mode state for each group chat. * Set when user sends a message with readOnly flag, cleared on next non-readOnly message. @@ -131,17 +268,43 @@ export function getPendingParticipants(groupChatId: string): Set { } /** - * Clears all pending participants for a group chat. + * Clears all pending participants for a group chat (and their timeouts). */ export function clearPendingParticipants(groupChatId: string): void { + // Cancel all timeouts for this chat before clearing + const pending = pendingParticipantResponses.get(groupChatId); + if (pending) { + for (const name of pending) { + clearParticipantResponseTimeout(groupChatId, name); + } + } pendingParticipantResponses.delete(groupChatId); + autoRunParticipantTracker.delete(groupChatId); +} + +/** + * Clears the active task session tracked for a participant. + */ +export function clearActiveParticipantTaskSession( + groupChatId: string, + participantName: string +): void { + clearActiveParticipantSession(groupChatId, participantName); } /** - * Marks a participant as having responded (removes from pending). + * Marks a participant as having responded (removes from pending, cancels timeout). * Returns true if this was the last pending participant. */ export function markParticipantResponded(groupChatId: string, participantName: string): boolean { + clearParticipantResponseTimeout(groupChatId, participantName); + + // Clean up autorun tracking for this participant + const autoRunSet = autoRunParticipantTracker.get(groupChatId); + if (autoRunSet?.delete(participantName) && autoRunSet.size === 0) { + autoRunParticipantTracker.delete(groupChatId); + } + const pending = pendingParticipantResponses.get(groupChatId); if (!pending) return false; @@ -174,6 +337,14 @@ export function setGetAgentConfigCallback(callback: GetAgentConfigCallback): voi getAgentConfigCallback = callback; } +/** + * Sets the callback for getting moderator settings (standing instructions + conductor profile). + * Called from index.ts during initialization. + */ +export function setGetModeratorSettingsCallback(callback: GetModeratorSettingsCallback): void { + getModeratorSettingsCallback = callback; +} + /** * Sets the SSH store for remote execution support. * Called from index.ts during initialization. @@ -182,9 +353,20 @@ export function setSshStore(store: SshRemoteSettingsStore): void { sshStore = store; } +/** + * Strips leading/trailing markdown formatting characters from a mention name. + * AI moderators often wrap mentions in bold/italic/code/strikethrough markdown + * (e.g. `**@name**`, `_@name_`, `` `@name` ``), which leaves formatting chars + * attached to the extracted name and breaks participant matching. + */ +function stripMarkdownFormatting(name: string): string { + return name.replace(/^[*_`~]+|[*_`~]+$/g, ''); +} + /** * Extracts @mentions from text that match known participants. * Supports hyphenated names matching participants with spaces. + * Handles markdown-formatted mentions (e.g. **@name**, _@name_). * * @param text - The text to search for mentions * @param participants - List of valid participants @@ -202,7 +384,8 @@ export function extractMentions(text: string, participants: GroupChatParticipant let match; while ((match = mentionPattern.exec(text)) !== null) { - const mentionedName = match[1]; + const mentionedName = stripMarkdownFormatting(match[1]); + if (!mentionedName) continue; // Find participant that matches (either exact or normalized) const matchingParticipant = participants.find((p) => mentionMatches(mentionedName, p.name)); if (matchingParticipant && !mentions.includes(matchingParticipant.name)) { @@ -215,6 +398,7 @@ export function extractMentions(text: string, participants: GroupChatParticipant /** * Extracts ALL @mentions from text (regardless of whether they're participants). + * Handles markdown-formatted mentions (e.g. **@name**, _@name_). * * @param text - The text to search for mentions * @returns Array of unique names that were mentioned (without @ prefix) @@ -231,7 +415,8 @@ export function extractAllMentions(text: string): string[] { let match; while ((match = mentionPattern.exec(text)) !== null) { - const name = match[1]; + const name = stripMarkdownFormatting(match[1]); + if (!name) continue; if (!mentions.includes(name)) { mentions.push(name); } @@ -240,6 +425,53 @@ export function extractAllMentions(text: string): string[] { return mentions; } +/** + * Extracts !autorun directives from moderator output. + * Matches `!autorun @AgentName` patterns. + * + * @param text - The moderator's message text + * @returns Object with autorun participant names and cleaned message text + */ +export interface AutoRunDirective { + participantName: string; + /** Specific filename to run, if specified (e.g. `!autorun @Agent:plan.md`). When present, + * only that document is executed instead of all docs in the folder. */ + filename?: string; +} + +export function extractAutoRunDirectives(text: string): { + autoRunDirectives: AutoRunDirective[]; + /** @deprecated use autoRunDirectives */ + autoRunParticipants: string[]; + cleanedText: string; +} { + const autoRunDirectives: AutoRunDirective[] = []; + // Matches: !autorun @AgentName OR !autorun @AgentName:filename.md + const autoRunPattern = /!autorun\s+@([^\s@:,;!?()\[\]{}'"<>]+)(?::([^\s,;!?()\[\]{}'"<>]+))?/g; + let match; + + while ((match = autoRunPattern.exec(text)) !== null) { + const participantName = stripMarkdownFormatting(match[1]); + if (!participantName) continue; + const filename = match[2]; // undefined when no :filename suffix + if (!autoRunDirectives.some((d) => d.participantName === participantName)) { + autoRunDirectives.push({ participantName, filename }); + } + } + + // Remove !autorun lines from the message for display + const cleanedText = text + .replace(/^.*!autorun\s+@[^\s@:,;!?()\[\]{}'"<>]+.*$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return { + autoRunDirectives, + autoRunParticipants: autoRunDirectives.map((d) => d.participantName), + cleanedText, + }; +} + /** * Routes a user message to the moderator. * @@ -414,7 +646,9 @@ export async function routeUserMessage( const participantContext = chat.participants.length > 0 ? chat.participants - .map((p) => `- @${normalizeMentionName(p.name)} (${p.agentId} session)`) + .map((p) => { + return `- @${normalizeMentionName(p.name)} (${p.agentId} session)`; + }) .join('\n') : '(No agents currently in this group chat)'; @@ -444,7 +678,24 @@ export async function routeUserMessage( .map((m) => `[${m.from}]: ${m.content}`) .join('\n'); - const fullPrompt = `${getModeratorSystemPrompt()} + // Get moderator settings for prompt customization + const moderatorSettings = getModeratorSettingsCallback?.() ?? { + standingInstructions: '', + conductorProfile: '', + }; + + // Substitute {{CONDUCTOR_PROFILE}} template variable + const baseSystemPrompt = getModeratorSystemPrompt().replace( + '{{CONDUCTOR_PROFILE}}', + moderatorSettings.conductorProfile || '(No conductor profile set)' + ); + + // Build standing instructions section if configured + const standingInstructionsSection = moderatorSettings.standingInstructions + ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${moderatorSettings.standingInstructions}` + : ''; + + const fullPrompt = `${baseSystemPrompt}${standingInstructionsSection} ## Current Participants: ${participantContext}${availableSessionsContext} @@ -633,40 +884,59 @@ export async function routeModeratorResponse( console.log(`[GroupChat:Debug] Chat loaded: "${chat.name}"`); - // Log the message as coming from moderator - await appendToLog(chat.logPath, 'moderator', message); - console.log(`[GroupChat:Debug] Message appended to log`); - - // Emit message event to renderer so it shows immediately - const moderatorMessage: GroupChatMessage = { - timestamp: new Date().toISOString(), - from: 'moderator', - content: message, - }; - groupChatEmitters.emitMessage?.(groupChatId, moderatorMessage); - console.log(`[GroupChat:Debug] Emitted moderator message to renderer`); + // Strip internal !autorun directives from the message before logging/display. + // These are machine-to-machine commands; storing them in the chat log causes + // the synthesis moderator to see them in history and potentially re-trigger them. + const { + autoRunDirectives, + autoRunParticipants, + cleanedText: displayMessage, + } = extractAutoRunDirectives(message); + + // Only persist/emit the moderator message if it has visible content after stripping directives + const shouldPersistModeratorMessage = displayMessage.trim().length > 0; + + if (shouldPersistModeratorMessage) { + // Log the message as coming from moderator (cleaned of !autorun directives) + await appendToLog(chat.logPath, 'moderator', displayMessage); + console.log(`[GroupChat:Debug] Message appended to log`); + + // Emit message event to renderer so it shows immediately + const moderatorMessage: GroupChatMessage = { + timestamp: new Date().toISOString(), + from: 'moderator', + content: displayMessage, + }; + groupChatEmitters.emitMessage?.(groupChatId, moderatorMessage); + console.log(`[GroupChat:Debug] Emitted moderator message to renderer`); + } // Add history entry for moderator response - try { - const summary = extractFirstSentence(message); - const historyEntry = await addGroupChatHistoryEntry(groupChatId, { - timestamp: Date.now(), - summary, - participantName: 'Moderator', - participantColor: '#808080', // Gray for moderator - type: 'response', - fullResponse: message, - }); + if (shouldPersistModeratorMessage) { + try { + const summary = extractFirstSentence(displayMessage); + const historyEntry = await addGroupChatHistoryEntry(groupChatId, { + timestamp: Date.now(), + summary, + participantName: 'Moderator', + participantColor: '#808080', // Gray for moderator + type: 'response', + fullResponse: displayMessage, + }); - // Emit history entry event to renderer - groupChatEmitters.emitHistoryEntry?.(groupChatId, historyEntry); - console.log( - `[GroupChatRouter] Added history entry for Moderator: ${summary.substring(0, 50)}...` - ); - } catch (error) { - logger.error('Failed to add history entry for Moderator', LOG_CONTEXT, { error, groupChatId }); - captureException(error, { operation: 'groupChat:addModeratorHistory', groupChatId }); - // Don't throw - history logging failure shouldn't break the message flow + // Emit history entry event to renderer + groupChatEmitters.emitHistoryEntry?.(groupChatId, historyEntry); + console.log( + `[GroupChatRouter] Added history entry for Moderator: ${summary.substring(0, 50)}...` + ); + } catch (error) { + logger.error('Failed to add history entry for Moderator', LOG_CONTEXT, { + error, + groupChatId, + }); + captureException(error, { operation: 'groupChat:addModeratorHistory', groupChatId }); + // Don't throw - history logging failure shouldn't break the message flow + } } // Extract ALL mentions from the message @@ -771,10 +1041,92 @@ export async function routeModeratorResponse( // Track participants that will need to respond for synthesis round const participantsToRespond = new Set(); - // Spawn batch processes for each mentioned participant - if (processManager && agentDetector && mentions.length > 0) { + // Use the !autorun directives already extracted above (same `message` input) + if (autoRunDirectives.length > 0) { + console.log( + `[GroupChat:Debug] Found !autorun directives for: ${autoRunDirectives.map((d) => (d.filename ? `${d.participantName}:${d.filename}` : d.participantName)).join(', ')}` + ); + } + + // Trigger Auto Run for participants via the renderer's batch processor + // This delegates to the renderer so the full useBatchProcessor pipeline runs: + // progress indicators, multi-document sequencing, task checking, achievements, etc. + if (autoRunDirectives.length > 0) { + console.log(`[GroupChat:Debug] ========== TRIGGERING AUTORUN VIA RENDERER ==========`); + const sessions = getSessionsCallback?.() || []; + + for (const directive of autoRunDirectives) { + const { participantName: autoRunName, filename: targetFilename } = directive; + const participant = updatedChat.participants.find((p) => mentionMatches(autoRunName, p.name)); + if (!participant) { + console.warn( + `[GroupChat:Debug] Autorun participant ${autoRunName} not found in chat - skipping` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ Could not find participant @${autoRunName} for !autorun. Make sure the agent is added to the group chat.`, + }); + continue; + } + + const matchingSession = sessions.find( + (s) => mentionMatches(s.name, participant.name) || s.name === participant.name + ); + + if (!matchingSession?.autoRunFolderPath) { + console.warn( + `[GroupChat:Debug] No autoRunFolderPath configured for ${participant.name} - skipping` + ); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ No Auto Run folder configured for @${participant.name}. Open the agent in Maestro, go to the Auto Run tab, and configure a folder first.`, + }); + continue; + } + + // Emit event to renderer — the renderer will call startBatchRun via useBatchProcessor. + // When the batch completes, the renderer calls groupChat:reportAutoRunComplete which + // invokes routeAgentResponse to trigger the synthesis round. + groupChatEmitters.emitParticipantState?.(groupChatId, participant.name, 'working'); + // Register in the global pending map BEFORE emitting the trigger event to the renderer. + // The renderer's batch processor could complete and call reportAutoRunComplete + // before the post-loop registration — this prevents that race. + participantsToRespond.add(participant.name); + pendingParticipantResponses.set(groupChatId, participantsToRespond); + setParticipantResponseTimeout( + groupChatId, + participant.name, + processManager ?? undefined, + agentDetector ?? undefined + ); + // Track as autorun so timeout path only emits batch-complete for autorun participants + if (!autoRunParticipantTracker.has(groupChatId)) { + autoRunParticipantTracker.set(groupChatId, new Set()); + } + autoRunParticipantTracker.get(groupChatId)!.add(participant.name); + // Emit 'agent-working' on first participant so UI indicators activate immediately + if (participantsToRespond.size === 1) { + groupChatEmitters.emitStateChange?.(groupChatId, 'agent-working'); + console.log(`[GroupChat:Debug] Emitted state change: agent-working`); + } + // Now emit the trigger — renderer will start the batch run + groupChatEmitters.emitAutoRunTriggered?.(groupChatId, participant.name, targetFilename); + console.log( + `[GroupChat:Debug] Emitted autoRunTriggered for @${participant.name}${targetFilename ? `:${targetFilename}` : ''} in chat ${groupChatId}` + ); + } + console.log(`[GroupChat:Debug] =================================================`); + } + + // Spawn batch processes for each mentioned participant (exclude autorun participants) + const mentionsToSpawn = mentions.filter( + (name) => !autoRunParticipants.some((arName) => mentionMatches(arName, name)) + ); + if (processManager && agentDetector && mentionsToSpawn.length > 0) { console.log(`[GroupChat:Debug] ========== SPAWNING PARTICIPANT AGENTS ==========`); - console.log(`[GroupChat:Debug] Will spawn ${mentions.length} participant agent(s)`); + console.log(`[GroupChat:Debug] Will spawn ${mentionsToSpawn.length} participant agent(s)`); // Get available sessions for cwd lookup const sessions = getSessionsCallback?.() || []; @@ -788,7 +1140,7 @@ export async function routeModeratorResponse( ) .join('\n'); - for (const participantName of mentions) { + for (const participantName of mentionsToSpawn) { console.log(`[GroupChat:Debug] --- Spawning participant: @${participantName} ---`); // Find the participant info @@ -965,9 +1317,25 @@ export async function routeModeratorResponse( ); console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`); console.log(`[GroupChat:Debug] noPromptSeparator: ${agent.noPromptSeparator ?? false}`); + setActiveParticipantSession(groupChatId, participantName, sessionId); - // Track this participant as pending response + // Register this participant in the global pending map IMMEDIATELY after spawn. + // This prevents a race condition where the process exits before the post-loop + // registration (the exit listener would call markParticipantResponded which checks + // this map — if the participant isn't registered yet, synthesis never triggers). participantsToRespond.add(participantName); + pendingParticipantResponses.set(groupChatId, participantsToRespond); + setParticipantResponseTimeout( + groupChatId, + participantName, + processManager ?? undefined, + agentDetector ?? undefined + ); + // Emit 'agent-working' on first spawn so sidebar and chat indicators update immediately + if (participantsToRespond.size === 1) { + groupChatEmitters.emitStateChange?.(groupChatId, 'agent-working'); + console.log(`[GroupChat:Debug] Emitted state change: agent-working`); + } console.log( `[GroupChat:Debug] Spawned batch process for participant @${participantName} (session ${sessionId}, readOnly=${readOnly ?? false})` ); @@ -985,24 +1353,37 @@ export async function routeModeratorResponse( } } console.log(`[GroupChat:Debug] =================================================`); - } else if (mentions.length === 0) { - console.log(`[GroupChat:Debug] No participant @mentions found - moderator response is final`); - // Set state back to idle since no agents are being spawned + } + + // If no actionable participant work was started (all directives invalid/skipped, no mentions), + // clean up lifecycle state so power blocks don't leak. + if (participantsToRespond.size === 0) { + console.log( + `[GroupChat:Debug] No actionable participant work started - moderator response is final` + ); + + // Unknown @tokens should be treated as plain text, not as a system error. + // Only emit a system warning here when explicit !autorun directives were present + // but none could be activated. + if (autoRunDirectives.length > 0 && mentions.length === 0) { + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: + '⚠️ The moderator included !autorun directives but none could be activated. You may need to send another message to retry.', + }); + } + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); console.log(`[GroupChat:Debug] Emitted state change: idle`); - // Remove power block reason since round is complete powerManager.removeBlockReason(`groupchat:${groupChatId}`); } - // Store pending participants for synthesis tracking + // Log final pending state (registration now happens incrementally per-participant above) if (participantsToRespond.size > 0) { - pendingParticipantResponses.set(groupChatId, participantsToRespond); console.log( `[GroupChat:Debug] Waiting for ${participantsToRespond.size} participant(s) to respond: ${[...participantsToRespond].join(', ')}` ); - // Set state to show agents are working - groupChatEmitters.emitStateChange?.(groupChatId, 'agent-working'); - console.log(`[GroupChat:Debug] Emitted state change: agent-working`); } console.log(`[GroupChat:Debug] ===================================================`); } @@ -1217,11 +1598,26 @@ export async function spawnModeratorSynthesis( const participantContext = chat.participants.length > 0 ? chat.participants - .map((p) => `- @${normalizeMentionName(p.name)} (${p.agentId} session)`) + .map((p) => { + return `- @${normalizeMentionName(p.name)} (${p.agentId} session)`; + }) .join('\n') : '(No agents currently in this group chat)'; - const synthesisPrompt = `${getModeratorSystemPrompt()} + // Get moderator settings for prompt customization + const synthModeratorSettings = getModeratorSettingsCallback?.() ?? { + standingInstructions: '', + conductorProfile: '', + }; + const synthBasePrompt = getModeratorSystemPrompt().replace( + '{{CONDUCTOR_PROFILE}}', + synthModeratorSettings.conductorProfile || '(No conductor profile set)' + ); + const synthStandingInstructions = synthModeratorSettings.standingInstructions + ? `\n\n## Standing Instructions\n\nThe following instructions apply to ALL group chat sessions. Follow them consistently:\n\n${synthModeratorSettings.standingInstructions}` + : ''; + + const synthesisPrompt = `${synthBasePrompt}${synthStandingInstructions} ${getModeratorSynthesisPrompt()} @@ -1233,8 +1629,10 @@ ${historyContext} ## Your Task: Review the agent responses above. Either: -1. Synthesize into a final answer for the user (NO @mentions) if the question is fully answered -2. @mention specific agents for follow-up if you need more information`; +1. Synthesize into a final answer for the user (NO @mentions, NO !autorun) if the question is fully answered +2. @mention specific agents for follow-up if you need more information + +**IMPORTANT: Do NOT include any !autorun directives in this synthesis response.**`; const agentConfigValues = getAgentConfigCallback?.(chat.moderatorAgentId) || {}; const baseArgs = buildAgentArgs(agent, { @@ -1496,5 +1894,6 @@ export async function respawnParticipantWithRecovery( console.log(`[GroupChat:Debug] Recovery spawn result: ${JSON.stringify(spawnResult)}`); console.log(`[GroupChat:Debug] promptArgs: ${agent.promptArgs ? 'defined' : 'undefined'}`); + setActiveParticipantSession(groupChatId, participantName, sessionId); console.log(`[GroupChat:Debug] =============================================`); } diff --git a/src/main/index.ts b/src/main/index.ts index 9826d5c370..91d42fa5ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -66,12 +66,14 @@ import { setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, + setGetModeratorSettingsCallback, setSshStore, setGetCustomShellPathCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery, + clearActiveParticipantTaskSession, } from './group-chat/group-chat-router'; import { createSshRemoteStoreAdapter } from './utils/ssh-remote-resolver'; import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage'; @@ -631,6 +633,8 @@ function setupIpcHandlers() { sshRemoteName, // Pass full SSH config for remote execution support sshRemoteConfig: s.sessionSshRemoteConfig, + autoRunFolderPath: s.autoRunFolderPath, + worktreeBasePath: s.worktreeConfig?.basePath, }; }); }); @@ -639,6 +643,12 @@ function setupIpcHandlers() { setGetCustomEnvVarsCallback(getCustomEnvVarsForAgent); setGetAgentConfigCallback(getAgentConfigForAgent); + // Set up callback for group chat router to get moderator standing instructions + conductor profile + setGetModeratorSettingsCallback(() => ({ + standingInstructions: (store.get('moderatorStandingInstructions', '') as string) || '', + conductorProfile: (store.get('conductorProfile', '') as string) || '', + })); + // Set up SSH store for group chat SSH remote execution support setSshStore(createSshRemoteStoreAdapter(store)); @@ -710,6 +720,7 @@ function setupProcessListeners() { spawnModeratorSynthesis, getGroupChatReadOnlyState, respawnParticipantWithRecovery, + clearActiveParticipantTaskSession, }, groupChatStorage: { loadGroupChat, diff --git a/src/main/ipc/handlers/groupChat.ts b/src/main/ipc/handlers/groupChat.ts index a2a97622b3..2a42209d4c 100644 --- a/src/main/ipc/handlers/groupChat.ts +++ b/src/main/ipc/handlers/groupChat.ts @@ -61,7 +61,13 @@ import { } from '../../group-chat/group-chat-agent'; // Group chat router imports -import { routeUserMessage } from '../../group-chat/group-chat-router'; +import { + routeUserMessage, + clearPendingParticipants, + routeAgentResponse, + markParticipantResponded, + spawnModeratorSynthesis, +} from '../../group-chat/group-chat-router'; // Agent detector import import { AgentDetector } from '../../agents'; @@ -100,6 +106,10 @@ export const groupChatEmitters: { state: ParticipantState ) => void; emitModeratorSessionIdChanged?: (groupChatId: string, sessionId: string) => void; + emitParticipantLiveOutput?: (groupChatId: string, participantName: string, chunk: string) => void; + emitAutoRunTriggered?: (groupChatId: string, participantName: string, filename?: string) => void; + /** Tells the renderer to force-complete the batch run for a participant (clears stuck AUTO badge). */ + emitAutoRunBatchComplete?: (groupChatId: string, participantName: string) => void; } = {}; // Helper to create handler options with consistent context @@ -464,6 +474,87 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v }) ); + // Stop all activity in a group chat (moderator + all participants) + ipcMain.handle( + 'groupChat:stopAll', + withIpcErrorLogging(handlerOpts('stopAll'), async (id: string): Promise => { + const processManager = getProcessManager(); + logger.info(`Stopping all activity in group chat: ${id}`, LOG_CONTEXT); + + // Kill moderator and all participant sessions + await killModerator(id, processManager ?? undefined); + await clearAllParticipantSessions(id, processManager ?? undefined); + + // Clear pending participant tracking so next round starts clean. + // Without this, a subsequent user message would inherit the old pending Set + // and trigger synthesis prematurely when those (now-dead) processes "respond". + clearPendingParticipants(id); + + // Load participants to emit idle states for each + const chat = await loadGroupChat(id); + if (chat) { + for (const participant of chat.participants) { + groupChatEmitters.emitParticipantState?.(id, participant.name, 'idle'); + } + } + + // Emit idle state for the group chat + groupChatEmitters.emitStateChange?.(id, 'idle'); + + logger.info(`Stopped all activity in group chat: ${id}`, LOG_CONTEXT); + }) + ); + + // Report that an Auto Run batch triggered by !autorun has completed + // Called by the renderer's batch processor onComplete handler to notify the + // group chat router so it can trigger the synthesis round. + ipcMain.handle( + 'groupChat:reportAutoRunComplete', + withIpcErrorLogging( + handlerOpts('reportAutoRunComplete'), + async (groupChatId: string, participantName: string, summary: string): Promise => { + logger.info( + `Auto Run complete for participant ${participantName} in ${groupChatId}`, + LOG_CONTEXT + ); + const processManager = getProcessManager(); + + // Log the autorun summary as the participant's response + await routeAgentResponse( + groupChatId, + participantName, + summary, + processManager ?? undefined + ); + + // Reset participant state to idle (mirrors what exit-listener does for regular participants). + // Without this the participant card stays "Working" because no process exit fires for + // autorun participants (the batch runs in a separate Maestro session, not a group-chat session). + groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + + // Signal the renderer to definitively complete the batch run for this participant. + // In the happy path this is a no-op (COMPLETE_BATCH was already dispatched by startBatchRun). + // In edge cases (synthesis re-triggered a second batch, or the process was slow to exit) + // this ensures the AUTO badge and progress bar are always cleared. + groupChatEmitters.emitAutoRunBatchComplete?.(groupChatId, participantName); + + // Mark participant as done and trigger synthesis if all participants have responded. + // Unlike regular participants (whose process exit triggers this via exit-listener), + // autorun participants never exit a group-chat process — the batch runs as a separate + // Maestro session — so we must call markParticipantResponded here. + const agentDetector = getAgentDetector(); + const isLast = markParticipantResponded(groupChatId, participantName); + if (isLast && processManager && agentDetector) { + logger.info( + `All participants responded after autorun, spawning synthesis for ${groupChatId}`, + LOG_CONTEXT + ); + await spawnModeratorSynthesis(groupChatId, processManager, agentDetector); + } + } + ) + ); + // Get the moderator session ID (for checking if active) ipcMain.handle( 'groupChat:getModeratorSessionId', @@ -872,5 +963,62 @@ Respond with ONLY the summary text, no additional commentary.`; } }; + /** + * Emit an Auto Run trigger event to the renderer. + * Called when the moderator issues !autorun @AgentName so the renderer can + * start a proper batch run through useBatchProcessor for full UI feedback. + */ + groupChatEmitters.emitAutoRunTriggered = ( + groupChatId: string, + participantName: string, + filename?: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send( + 'groupChat:autoRunTriggered', + groupChatId, + participantName, + filename + ); + } + }; + + /** + * Tell the renderer to force-complete the batch run for an autorun participant. + * Fired on both normal completion (reportAutoRunComplete) and on the timeout path, + * so the AUTO badge and progress bar are always cleaned up regardless of how the + * participant's involvement ends. + */ + groupChatEmitters.emitAutoRunBatchComplete = ( + groupChatId: string, + participantName: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send('groupChat:autoRunBatchComplete', groupChatId, participantName); + } + }; + + /** + * Emit live output chunks from a participant to the renderer. + * Called as data streams in from participant processes. + */ + groupChatEmitters.emitParticipantLiveOutput = ( + groupChatId: string, + participantName: string, + chunk: string + ): void => { + const mainWindow = getMainWindow(); + if (isWebContentsAvailable(mainWindow)) { + mainWindow.webContents.send( + 'groupChat:participantLiveOutput', + groupChatId, + participantName, + chunk + ); + } + }; + logger.info('Registered Group Chat IPC handlers', LOG_CONTEXT); } diff --git a/src/main/preload/groupChat.ts b/src/main/preload/groupChat.ts index 692883c45b..9dfd8c791d 100644 --- a/src/main/preload/groupChat.ts +++ b/src/main/preload/groupChat.ts @@ -108,6 +108,11 @@ export function createGroupChatApi() { stopModerator: (id: string) => ipcRenderer.invoke('groupChat:stopModerator', id), + stopAll: (id: string) => ipcRenderer.invoke('groupChat:stopAll', id), + + reportAutoRunComplete: (groupChatId: string, participantName: string, summary: string) => + ipcRenderer.invoke('groupChat:reportAutoRunComplete', groupChatId, participantName, summary), + getModeratorSessionId: (id: string) => ipcRenderer.invoke('groupChat:getModeratorSessionId', id), @@ -204,6 +209,31 @@ export function createGroupChatApi() { return () => ipcRenderer.removeListener('groupChat:participantState', handler); }, + onParticipantLiveOutput: ( + callback: (groupChatId: string, participantName: string, chunk: string) => void + ) => { + const handler = (_: any, groupChatId: string, participantName: string, chunk: string) => + callback(groupChatId, participantName, chunk); + ipcRenderer.on('groupChat:participantLiveOutput', handler); + return () => ipcRenderer.removeListener('groupChat:participantLiveOutput', handler); + }, + + onAutoRunTriggered: ( + callback: (groupChatId: string, participantName: string, filename?: string) => void + ) => { + const handler = (_: any, groupChatId: string, participantName: string, filename?: string) => + callback(groupChatId, participantName, filename); + ipcRenderer.on('groupChat:autoRunTriggered', handler); + return () => ipcRenderer.removeListener('groupChat:autoRunTriggered', handler); + }, + + onAutoRunBatchComplete: (callback: (groupChatId: string, participantName: string) => void) => { + const handler = (_: any, groupChatId: string, participantName: string) => + callback(groupChatId, participantName); + ipcRenderer.on('groupChat:autoRunBatchComplete', handler); + return () => ipcRenderer.removeListener('groupChat:autoRunBatchComplete', handler); + }, + onModeratorSessionIdChanged: (callback: (groupChatId: string, sessionId: string) => void) => { const handler = (_: any, groupChatId: string, sessionId: string) => callback(groupChatId, sessionId); diff --git a/src/main/process-listeners/__tests__/exit-listener.test.ts b/src/main/process-listeners/__tests__/exit-listener.test.ts index 9a6dc25017..1c651e8d3c 100644 --- a/src/main/process-listeners/__tests__/exit-listener.test.ts +++ b/src/main/process-listeners/__tests__/exit-listener.test.ts @@ -63,6 +63,7 @@ describe('Exit Listener', () => { spawnModeratorSynthesis: vi.fn().mockResolvedValue(undefined), getGroupChatReadOnlyState: vi.fn().mockReturnValue(false), respawnParticipantWithRecovery: vi.fn().mockResolvedValue(undefined), + clearActiveParticipantTaskSession: vi.fn(), }, groupChatStorage: { loadGroupChat: vi.fn().mockResolvedValue(createMockGroupChat()), diff --git a/src/main/process-listeners/data-listener.ts b/src/main/process-listeners/data-listener.ts index b958029467..32ff39bb2d 100644 --- a/src/main/process-listeners/data-listener.ts +++ b/src/main/process-listeners/data-listener.ts @@ -5,6 +5,7 @@ import type { ProcessManager } from '../process-manager'; import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types'; +import { groupChatEmitters } from '../ipc/handlers/groupChat'; /** * Maximum buffer size per session (10MB). @@ -41,6 +42,21 @@ export function setupDataListener( REGEX_SYNOPSIS_SESSION, } = patterns; + // Listen to raw stdout for live output streaming to group chat participant peek panels. + // The 'data' event for stream-json sessions only fires at turn completion (result ready), + // so we need raw-stdout to stream chunks in real time during agent work. + processManager.on('raw-stdout', (sessionId: string, chunk: string) => { + if (!sessionId.startsWith(GROUP_CHAT_PREFIX)) return; + const participantInfo = outputParser.parseParticipantSessionId(sessionId); + if (participantInfo) { + groupChatEmitters.emitParticipantLiveOutput?.( + participantInfo.groupChatId, + participantInfo.participantName, + chunk + ); + } + }); + processManager.on('data', (sessionId: string, data: string) => { // Fast path: skip regex for non-group-chat sessions (performance optimization) // Most sessions don't start with 'group-chat-', so this avoids expensive regex matching @@ -91,6 +107,7 @@ export function setupDataListener( `WARNING: Buffer size ${totalLength} exceeds ${MAX_BUFFER_SIZE} bytes for participant ${participantInfo.participantName}` ); } + // Note: live output is streamed via raw-stdout listener above (fires per chunk during work). return; // Don't send to regular process:data handler } diff --git a/src/main/process-listeners/exit-listener.ts b/src/main/process-listeners/exit-listener.ts index b06b5a5963..ebdeabb315 100644 --- a/src/main/process-listeners/exit-listener.ts +++ b/src/main/process-listeners/exit-listener.ts @@ -5,6 +5,7 @@ */ import type { ProcessManager } from '../process-manager'; +import { captureException } from '../utils/sentry'; import { GROUP_CHAT_PREFIX, type ProcessListenerDependencies } from './types'; /** @@ -212,6 +213,7 @@ export function setupExitListener( // Emit participant state change to show this participant is done working groupChatEmitters.emitParticipantState?.(groupChatId, participantName, 'idle'); + groupChatRouter.clearActiveParticipantTaskSession(groupChatId, participantName); debugLog('GroupChat:Debug', ` Emitted participant state: idle`); // Route the buffered output now that process is complete @@ -243,6 +245,17 @@ export function setupExitListener( error: String(err), groupChatId, }); + // Reset to idle so user is not stuck waiting indefinitely + groupChatEmitters.emitStateChange?.(groupChatId, 'idle'); + groupChatEmitters.emitMessage?.(groupChatId, { + timestamp: new Date().toISOString(), + from: 'system', + content: `⚠️ Synthesis failed. You can send another message to continue.`, + }); + captureException(err, { + operation: 'groupChat:spawnModeratorSynthesis', + groupChatId, + }); }); } else if (!isLastParticipant) { // More participants pending diff --git a/src/main/process-listeners/types.ts b/src/main/process-listeners/types.ts index bb6a0a2527..fe1be4e92b 100644 --- a/src/main/process-listeners/types.ts +++ b/src/main/process-listeners/types.ts @@ -105,6 +105,7 @@ export interface ProcessListenerDependencies { processManager: ProcessManager, agentDetector: AgentDetector ) => Promise; + clearActiveParticipantTaskSession: (groupChatId: string, participantName: string) => void; }; /** Group chat storage functions */ groupChatStorage: { diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index b66f9c7531..ddded18792 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -429,6 +429,16 @@ export class ChildProcessSpawner { }); childProcess.stdout.on('data', (data: Buffer | string) => { const output = data.toString(); + // Emit raw stdout before processing for live-streaming consumers (e.g., group chat peek). + // Wrapped in try/catch so a failing listener cannot prevent stdoutHandler from running. + try { + this.emitter.emit('raw-stdout', sessionId, output); + } catch (err) { + logger.error('[ProcessManager] raw-stdout listener error', 'ProcessManager', { + sessionId, + error: String(err), + }); + } this.stdoutHandler.handleData(sessionId, output); }); } else { diff --git a/src/prompts/group-chat-moderator-synthesis.md b/src/prompts/group-chat-moderator-synthesis.md index 5e6ebfb7fb..d566c7c082 100644 --- a/src/prompts/group-chat-moderator-synthesis.md +++ b/src/prompts/group-chat-moderator-synthesis.md @@ -8,6 +8,8 @@ You are reviewing responses from AI agents in a group chat. 3. **If the agents didn't answer the question** - @mention them again with clearer instructions. Don't give up until the user's question is answered. +4. **If an agent has already created or updated an Auto Run document and you want that document executed** - do not ask them to run it via a normal `@mention`. Use `!autorun @AgentName:path/to/doc.md` with the exact relative path the agent confirmed. + ## Important: - Your job is to ensure the user gets a complete answer diff --git a/src/prompts/group-chat-moderator-system.md b/src/prompts/group-chat-moderator-system.md index 9e6f3da2d8..0b5a7fac27 100644 --- a/src/prompts/group-chat-moderator-system.md +++ b/src/prompts/group-chat-moderator-system.md @@ -29,3 +29,25 @@ Your role is to: - If you need multiple rounds of work, keep @mentioning agents until the task is complete - Only return to the user when you have a complete, actionable answer - When you're done and ready to hand back to the user, provide a summary WITHOUT any @mentions + +## Auto Run Execution: + +- Use `!autorun @AgentName:filename.md` to trigger execution of a **specific** Auto Run document the agent just created or updated +- Use `!autorun @AgentName` (without filename) only when you want to run ALL documents in the agent's Auto Run folder +- **Always prefer the specific filename form** after an agent confirms creating or updating a document — this guarantees the right file is executed +- **Never ask an agent to execute/run/process an Auto Run document via a regular `@Agent` message.** Auto Run document execution must go through `!autorun`, not a normal participant prompt +- Require the agent to report the document path **relative to its Auto Run folder** (for example `plans/frontend-plan.md`) and then reuse that exact relative path in the `!autorun` command +- Multiple agents can be triggered in parallel: + !autorun @Agent1:frontend-plan.md + !autorun @Agent2:backend-plan.md +- Use this AFTER agents have confirmed their implementation plans as Auto Run documents +- Do NOT combine !autorun with a regular @mention for the same agent in the same message +- **Important**: Ask the agent to confirm the exact relative path of the document it created before issuing !autorun + +## Commit & Switch Branch: + +- When the user sends `!commit`, instruct ALL participating agents to: + 1. Commit all staged and unstaged changes on their current branch with a descriptive commit message +- @mention each agent with clear, specific instructions +- After all agents respond, provide a summary with each agent's branch name and commit status +- If an agent reports conflicts or errors, relay them clearly to the user diff --git a/src/prompts/group-chat-participant-request.md b/src/prompts/group-chat-participant-request.md index 50c97b14f8..8020e4f265 100644 --- a/src/prompts/group-chat-participant-request.md +++ b/src/prompts/group-chat-participant-request.md @@ -24,4 +24,12 @@ The shared folder contains chat logs and can be used for collaborative file exch {{MESSAGE}} +## Auto Run Guardrail + +If the moderator asks you to execute, run, or process an Auto Run document or Playbook, do **not** execute that document directly in this reply. Instead: + +- report the exact document path relative to your Auto Run folder +- state that the moderator should trigger it via `!autorun @{{PARTICIPANT_NAME}}:.md` +- only execute the document when Maestro starts the native Auto Run flow + Please respond to this request.{{READ_ONLY_INSTRUCTION}} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7d7fbcd8ce..41803f7fe4 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -151,6 +151,8 @@ import { useModalActions, useModalStore } from './stores/modalStore'; import { GitStatusProvider } from './contexts/GitStatusContext'; import { InputProvider, useInputContext } from './contexts/InputContext'; import { useGroupChatStore } from './stores/groupChatStore'; +import { registerGroupChatAutoRun } from './utils/groupChatAutoRunRegistry'; +import { resolveGroupChatAutoRunTarget } from './utils/groupChatAutoRun'; import { useBatchStore } from './stores/batchStore'; // All session state is read directly from useSessionStore in MaestroConsoleInner. import { useSessionStore, selectActiveSession } from './stores/sessionStore'; @@ -846,6 +848,7 @@ function MaestroConsoleInner() { handleOpenModeratorSession, handleJumpToGroupChatMessage, handleGroupChatRightTabChange, + handleStopAll, handleSendGroupChatMessage, handleGroupChatDraftChange, handleRemoveGroupChatQueueItem, @@ -1257,6 +1260,91 @@ function MaestroConsoleInner() { handleClearAgentError, }); + // --- GROUP CHAT AUTO RUN BRIDGE --- + // When the moderator issues !autorun @AgentName, the main process emits + // groupChat:autoRunTriggered. Here we intercept that, find the session, + // and start a proper batch run via useBatchProcessor for full UI feedback. + const startBatchRunRef = useRef(startBatchRun); + startBatchRunRef.current = startBatchRun; + + useEffect(() => { + const unsub = window.maestro.groupChat.onAutoRunTriggered?.( + (groupChatId, participantName, targetFilename) => { + // Helper: report failure back to the group chat as a system message so the + // moderator and user can see what went wrong and take corrective action. + const reportFailure = (reason: string) => { + console.warn(`[GroupChat:AutoRun] ${reason}`); + window.maestro.groupChat + .reportAutoRunComplete( + groupChatId, + participantName, + `⚠️ Auto Run could not start for @${participantName}: ${reason}` + ) + .catch((e) => + console.error('[GroupChat:AutoRun] Failed to report failure to moderator:', e) + ); + }; + + const sessions = useSessionStore.getState().sessions; + const session = sessions.find((s) => s.name === participantName); + if (!session) { + reportFailure( + `No Maestro agent named "${participantName}" found. Make sure the agent exists and is open.` + ); + return; + } + if (!session.autoRunFolderPath) { + reportFailure( + `Agent "${participantName}" has no Auto Run folder configured. Open the agent, go to the Auto Run tab, and configure a folder first.` + ); + return; + } + + // Fetch the document list, then start the batch run + window.maestro.autorun + .listDocs(session.autoRunFolderPath, session.sshRemoteId || undefined) + .then((result) => { + const allFiles = result.files || []; + if (allFiles.length === 0) { + reportFailure( + `No Auto Run documents found in "${session.autoRunFolderPath}". Create a document in the Auto Run tab first.` + ); + return; + } + + const resolvedTarget = resolveGroupChatAutoRunTarget(allFiles, targetFilename); + if ('error' in resolvedTarget) { + reportFailure( + `${resolvedTarget.error} in "${session.autoRunFolderPath}" for "${participantName}".` + ); + return; + } + const files = resolvedTarget.files; + + const documents = files.map((filename, i) => ({ + id: `${session.id}-${i}`, + filename, + resetOnCompletion: false, + isDuplicate: false, + })); + const config = { + documents, + prompt: '', + loopEnabled: false, + maxLoops: null, + }; + // Register AFTER validating docs exist so no stale entry on failure + registerGroupChatAutoRun(session.id, groupChatId, participantName); + startBatchRunRef.current(session.id, config, session.autoRunFolderPath!); + }) + .catch((err) => { + reportFailure(`Failed to read Auto Run folder: ${String(err)}`); + }); + } + ); + return () => unsub?.(); + }, []); // Stable — reads sessions and startBatchRun from refs/store at call time + // --- AGENT IPC LISTENERS --- // Extracted hook for all window.maestro.process.onXxx listeners // (onData, onExit, onSessionId, onSlashCommands, onStderr, onCommandExit, @@ -3077,6 +3165,7 @@ function MaestroConsoleInner() { return anyParticipantMissingCost || moderatorMissingCost; })()} onSendMessage={handleSendGroupChatMessage} + onStopAll={handleStopAll} onRename={() => activeGroupChatId && handleOpenRenameGroupChatModal(activeGroupChatId) } diff --git a/src/renderer/components/GroupChatHeader.tsx b/src/renderer/components/GroupChatHeader.tsx index 637a899b5c..436df04982 100644 --- a/src/renderer/components/GroupChatHeader.tsx +++ b/src/renderer/components/GroupChatHeader.tsx @@ -5,8 +5,8 @@ * and provides actions for rename and info. */ -import { Info, Edit2, Columns, DollarSign } from 'lucide-react'; -import type { Theme, Shortcut } from '../types'; +import { Info, Edit2, Columns, DollarSign, StopCircle } from 'lucide-react'; +import type { Theme, Shortcut, GroupChatState } from '../types'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; interface GroupChatHeaderProps { @@ -17,6 +17,8 @@ interface GroupChatHeaderProps { totalCost?: number; /** True if one or more participants don't have cost data (makes total incomplete) */ costIncomplete?: boolean; + state: GroupChatState; + onStopAll: () => void; onRename: () => void; onShowInfo: () => void; rightPanelOpen: boolean; @@ -30,6 +32,8 @@ export function GroupChatHeader({ participantCount, totalCost, costIncomplete, + state, + onStopAll, onRename, onShowInfo, rightPanelOpen, @@ -72,6 +76,22 @@ export function GroupChatHeader({
+ {/* Stop All button - only shown when active */} + {state !== 'idle' && ( + + )} void; + onStopAll: () => void; onRename: () => void; onShowInfo: () => void; rightPanelOpen: boolean; @@ -80,6 +81,7 @@ export function GroupChatPanel({ totalCost, costIncomplete, onSendMessage, + onStopAll, onRename, onShowInfo, rightPanelOpen, @@ -117,6 +119,8 @@ export function GroupChatPanel({ participantCount={groupChat.participants.length} totalCost={totalCost} costIncomplete={costIncomplete} + state={state} + onStopAll={onStopAll} onRename={onRename} onShowInfo={onShowInfo} rightPanelOpen={rightPanelOpen} diff --git a/src/renderer/components/GroupChatParticipants.tsx b/src/renderer/components/GroupChatParticipants.tsx index 362db0fda8..27e9d12610 100644 --- a/src/renderer/components/GroupChatParticipants.tsx +++ b/src/renderer/components/GroupChatParticipants.tsx @@ -13,6 +13,7 @@ import { ParticipantCard } from './ParticipantCard'; import { formatShortcutKeys } from '../utils/shortcutFormatter'; import { buildParticipantColorMap } from '../utils/participantColors'; import { useResizablePanel } from '../hooks'; +import { useGroupChatStore } from '../stores/groupChatStore'; interface GroupChatParticipantsProps { theme: Theme; @@ -62,6 +63,8 @@ export function GroupChatParticipants({ side: 'right', }); + const participantLiveOutput = useGroupChatStore((s) => s.participantLiveOutput); + // Generate consistent colors for all participants (including "Moderator" for the moderator card) const participantColors = useMemo(() => { return buildParticipantColorMap(['Moderator', ...participants.map((p) => p.name)], theme); @@ -164,10 +167,11 @@ export function GroupChatParticipants({ key={participant.sessionId} theme={theme} participant={participant} - state={participantStates.get(participant.sessionId) || 'idle'} + state={participantStates.get(participant.name) || 'idle'} color={participantColors[participant.name]} groupChatId={groupChatId} onContextReset={handleContextReset} + liveOutput={participantLiveOutput.get(participant.name)} /> )) )} diff --git a/src/renderer/components/GroupChatRightPanel.tsx b/src/renderer/components/GroupChatRightPanel.tsx index b8a2ee41c3..cbdf0b8406 100644 --- a/src/renderer/components/GroupChatRightPanel.tsx +++ b/src/renderer/components/GroupChatRightPanel.tsx @@ -20,6 +20,7 @@ import { type ParticipantColorInfo, } from '../utils/participantColors'; import { useResizablePanel } from '../hooks'; +import { useGroupChatStore } from '../stores/groupChatStore'; export type GroupChatRightTab = 'participants' | 'history'; @@ -80,6 +81,8 @@ export function GroupChatRightPanel({ onJumpToMessage, onColorsComputed, }: GroupChatRightPanelProps): JSX.Element | null { + const participantLiveOutput = useGroupChatStore((s) => s.participantLiveOutput); + // Color preferences state const [colorPreferences, setColorPreferences] = useState>({}); const { panelRef, onResizeStart, transitionClass } = useResizablePanel({ @@ -322,6 +325,7 @@ export function GroupChatRightPanel({ color={participantColors[participant.name]} groupChatId={groupChatId} onContextReset={handleContextReset} + liveOutput={participantLiveOutput.get(`${groupChatId}:${participant.name}`)} /> ); }) diff --git a/src/renderer/components/ParticipantCard.tsx b/src/renderer/components/ParticipantCard.tsx index 2aec2b916c..c31e4f2de1 100644 --- a/src/renderer/components/ParticipantCard.tsx +++ b/src/renderer/components/ParticipantCard.tsx @@ -5,8 +5,17 @@ * session ID, context usage, stats, and cost. */ -import { MessageSquare, Copy, Check, DollarSign, RotateCcw, Server } from 'lucide-react'; -import { useState, useCallback } from 'react'; +import { + MessageSquare, + Copy, + Check, + DollarSign, + RotateCcw, + Server, + Eye, + EyeOff, +} from 'lucide-react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import type { Theme, GroupChatParticipant, SessionState } from '../types'; import { getStatusColor } from '../utils/theme'; import { formatCost } from '../utils/formatters'; @@ -19,6 +28,7 @@ interface ParticipantCardProps { color?: string; groupChatId?: string; onContextReset?: (participantName: string) => void; + liveOutput?: string; } /** @@ -39,9 +49,19 @@ export function ParticipantCard({ color, groupChatId, onContextReset, + liveOutput, }: ParticipantCardProps): JSX.Element { const [copied, setCopied] = useState(false); const [isResetting, setIsResetting] = useState(false); + const [peekOpen, setPeekOpen] = useState(false); + const peekRef = useRef(null); + + // Auto-scroll peek output to bottom + useEffect(() => { + if (peekOpen && peekRef.current) { + peekRef.current.scrollTop = peekRef.current.scrollHeight; + } + }, [peekOpen, liveOutput]); // Use agent's session ID (clean GUID) when available, otherwise show pending const agentSessionId = participant.agentSessionId; @@ -236,7 +256,41 @@ export function ParticipantCard({ Resetting... )} + {/* Peek button - always visible */} +
+ + {/* Live output peek panel */} + {peekOpen && ( +
+					{liveOutput
+						? liveOutput.length > 4096
+							? liveOutput.slice(-4096)
+							: liveOutput
+						: '(no live output yet)'}
+				
+ )} ); } diff --git a/src/renderer/components/Settings/SettingsModal.tsx b/src/renderer/components/Settings/SettingsModal.tsx index a1d665dc0a..2aaa2a9b50 100644 --- a/src/renderer/components/Settings/SettingsModal.tsx +++ b/src/renderer/components/Settings/SettingsModal.tsx @@ -10,6 +10,7 @@ import { FlaskConical, Server, Monitor, + Users, } from 'lucide-react'; import { useSettings } from '../../hooks'; import type { Theme, LLMProvider } from '../../types'; @@ -45,6 +46,7 @@ interface SettingsModalProps { | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore'; hasNoAgents?: boolean; @@ -92,6 +94,9 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro setSshRemoteIgnorePatterns, sshRemoteHonorGitignore, setSshRemoteHonorGitignore, + // Group Chat settings + moderatorStandingInstructions, + setModeratorStandingInstructions, } = useSettings(); const [activeTab, setActiveTab] = useState< @@ -102,6 +107,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore' >('general'); @@ -166,6 +172,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro | 'theme' | 'notifications' | 'aicommands' + | 'groupchat' | 'ssh' | 'encore' > = FEATURE_FLAGS.LLM_SETTINGS @@ -177,6 +184,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', + 'groupchat', 'ssh', 'encore', ] @@ -187,6 +195,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro 'theme', 'notifications', 'aicommands', + 'groupchat', 'ssh', 'encore', ]; @@ -391,6 +400,14 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro {activeTab === 'aicommands' && AI Commands} +