diff --git a/.gitignore b/.gitignore index 4f794fa5..8f9ceab5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ docs/superpowers/ # Debug / temp screenshots (root dir only) /*.png +bug_attachments/ # Private keys & credentials *.pem diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index a99b7852..b08875ab 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -868,8 +868,33 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'error': runtime.state = 'idle' runtime.verb = undefined - // 如果 streaming card 存在就把错误渲染到卡上,否则 fallback 到 sendText - if (streamingCards.has(chatId)) { + // Auto-recover from stale thinking block signatures by creating a fresh session. + if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) { + // Abort any in-flight streaming card first + if (streamingCards.has(chatId)) { + const card = streamingCards.get(chatId)! + streamingCards.delete(chatId) + void card.abort(new Error('session reset')).catch(() => {}) + } + const stored = sessionStore.get(chatId) + const workDir = stored?.workDir || config.defaultProjectDir + if (workDir) { + await sendText(chatId, '⚠️ 会话上下文已失效,正在自动重建...') + bridge.resetSession(chatId) + sessionStore.delete(chatId) + imageWatchers.delete(chatId) + uploadedImageKeys.delete(chatId) + runtimeStates.delete(chatId) + const ok = await createSessionForChat(chatId, workDir) + if (ok) { + await sendText(chatId, '✅ 已重建会话,请重新发送消息。') + } else { + await sendText(chatId, '❌ 重建会话失败,请发送 /new 手动新建。') + } + } else { + await sendText(chatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。') + } + } else if (streamingCards.has(chatId)) { await abortStreamingCard(chatId, new Error(msg.message ?? 'unknown error')) } else { await sendText(chatId, `❌ ${msg.message}`) diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 2943fb85..aa84676d 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -237,6 +237,11 @@ async function ensureSession(chatId: string): Promise { async function createSessionForChat(chatId: string, workDir: string): Promise { const numericChatId = Number(chatId) try { + // Always tear down any stale WS connection before creating a new session. + // Without this, bridge.connectSession() below would short-circuit when an + // old OPEN connection still exists, leaving messages routed to the old session. + bridge.resetSession(chatId) + const sessionId = await httpClient.createSession(workDir) sessionStore.set(chatId, sessionId, workDir) bridge.connectSession(chatId, sessionId) @@ -435,7 +440,28 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< case 'error': runtime.state = 'idle' runtime.verb = undefined - await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`) + // Auto-recover from stale thinking block signatures by creating a fresh session. + // This happens when the API key or provider changed since the session was created. + if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) { + const stored = sessionStore.get(chatId) + const workDir = stored?.workDir || config.defaultProjectDir + if (workDir) { + await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...') + clearTransientChatState(chatId) + bridge.resetSession(chatId) + sessionStore.delete(chatId) + const ok = await createSessionForChat(chatId, workDir) + if (ok) { + await bot.api.sendMessage(numericChatId, '✅ 已重建会话,请重新发送消息。') + } else { + await bot.api.sendMessage(numericChatId, '❌ 重建会话失败,请发送 /new 手动新建。') + } + } else { + await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。') + } + } else { + await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`) + } break case 'system_notification': diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index fcdf668e..b38dd120 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -6,7 +6,6 @@ import '@testing-library/jest-dom' import { EmptySession } from '../pages/EmptySession' import { ActiveSession } from '../pages/ActiveSession' import { AgentTeams } from '../pages/AgentTeams' -import { AgentTranscript } from '../pages/AgentTranscript' import { ScheduledTasks } from '../pages/ScheduledTasks' import { ToolInspection } from '../pages/ToolInspection' @@ -107,13 +106,6 @@ describe('Content-only pages render without errors', () => { expect(container.innerHTML).toContain('groups') }) - it('AgentTranscript renders transcript content', () => { - const { container } = render() - expect(container.innerHTML).toContain('Frontend Dev') - expect(container.innerHTML).toContain('Back to Leader') - expect(container.innerHTML).toContain('Navigation.tsx') - }) - it('ScheduledTasks renders (store-connected)', async () => { const { container } = render() await screen.findByText('Scheduled tasks') @@ -160,7 +152,7 @@ describe('AppShell layout renders chrome', () => { describe('Design system compliance', () => { it('Pages use Material Symbols Outlined icons', () => { - const pages = [EmptySession, AgentTeams, AgentTranscript, ToolInspection] + const pages = [EmptySession, AgentTeams, ToolInspection] for (const Page of pages) { const { container, unmount } = render() const icons = container.querySelectorAll('.material-symbols-outlined') @@ -193,9 +185,4 @@ describe('Mock data integration', () => { expect(container.innerHTML).toContain('Backend Dev') expect(container.innerHTML).toContain('Tester') }) - - it('AgentTranscript shows transcript content', () => { - const { container } = render() - expect(container.innerHTML).toContain('Navigation.tsx') - }) }) diff --git a/desktop/src/api/teams.ts b/desktop/src/api/teams.ts index f355915e..2c0dfe94 100644 --- a/desktop/src/api/teams.ts +++ b/desktop/src/api/teams.ts @@ -1,9 +1,21 @@ import { api } from './client' -import type { TeamSummary, TeamDetail, TranscriptMessage } from '../types/team' +import type { TeamSummary, TeamDetail } from '../types/team' type TeamsResponse = { teams: TeamSummary[] } + +type TranscriptMessage = { + id: string + type: string + content: unknown + timestamp: string + model?: string + parentToolUseId?: string +} + type TranscriptResponse = { messages: TranscriptMessage[] } +export type { TranscriptMessage } + export const teamsApi = { list() { return api.get('/api/teams') @@ -19,6 +31,13 @@ export const teamsApi = { ) }, + sendMemberMessage(teamName: string, agentId: string, content: string) { + return api.post<{ ok: true }>( + `/api/teams/${encodeURIComponent(teamName)}/members/${encodeURIComponent(agentId)}/messages`, + { content }, + ) + }, + delete(name: string) { return api.delete<{ ok: true }>(`/api/teams/${encodeURIComponent(name)}`) }, diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 191fea48..4dcfe1cb 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '../../i18n' import { useChatStore } from '../../stores/chatStore' import { useTabStore } from '../../stores/tabStore' import { useSessionStore } from '../../stores/sessionStore' +import { useTeamStore } from '../../stores/teamStore' import { sessionsApi } from '../../api/sessions' import { PermissionModeSelector } from '../controls/PermissionModeSelector' import { ModelSelector } from '../controls/ModelSelector' @@ -53,12 +54,14 @@ export function ChatInput() { const chatState = sessionState?.chatState ?? 'idle' const slashCommands = sessionState?.slashCommands ?? [] const activeSession = useSessionStore((state) => activeTabId ? state.sessions.find((session) => session.id === activeTabId) ?? null : null) + const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) const [gitInfo, setGitInfo] = useState(null) const hasMessages = useChatStore((s) => activeTabId ? (s.sessions[activeTabId]?.messages?.length ?? 0) > 0 : false) + const isMemberSession = !!memberInfo const isActive = chatState !== 'idle' const isWorkspaceMissing = activeSession?.workDirExists === false - const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || attachments.length > 0) + const canSubmit = !isWorkspaceMissing && (input.trim().length > 0 || (!isMemberSession && attachments.length > 0)) useEffect(() => { textareaRef.current?.focus() @@ -69,8 +72,20 @@ export function ChatInput() { setGitInfo(null) return } + if (isMemberSession) { + setGitInfo(null) + return + } sessionsApi.getGitInfo(activeTabId).then(setGitInfo).catch(() => setGitInfo(null)) - }, [activeTabId]) + }, [activeTabId, isMemberSession]) + + useEffect(() => { + if (!isMemberSession) return + setAttachments([]) + setPlusMenuOpen(false) + setSlashMenuOpen(false) + setFileSearchOpen(false) + }, [isMemberSession, activeTabId]) useEffect(() => { const el = textareaRef.current @@ -191,6 +206,10 @@ export function ChatInput() { const handleInputChange = (event: React.ChangeEvent) => { const value = event.target.value + if (isMemberSession) { + setInput(value) + return + } const cursorPos = event.target.selectionStart ?? value.length setInput(value) detectSlashTrigger(value, cursorPos) @@ -212,7 +231,7 @@ export function ChatInput() { const handleSubmit = () => { const text = input.trim() - if ((!text && attachments.length === 0) || isWorkspaceMissing) return + if ((!text && (!attachments.length || isMemberSession)) || isWorkspaceMissing) return const attachmentPayload: AttachmentRef[] = attachments.map((attachment) => ({ type: attachment.type, @@ -224,7 +243,9 @@ export function ChatInput() { sendMessage(activeTabId!, text, attachmentPayload) setInput('') setAttachments([]) + setPlusMenuOpen(false) setSlashMenuOpen(false) + setFileSearchOpen(false) } const handleKeyDown = (event: React.KeyboardEvent) => { @@ -280,6 +301,7 @@ export function ChatInput() { } const handlePaste = (event: React.ClipboardEvent) => { + if (isMemberSession) return const items = event.clipboardData?.items if (!items) return @@ -315,6 +337,7 @@ export function ChatInput() { } const handleFileSelect = (event: React.ChangeEvent) => { + if (isMemberSession) return const files = event.target.files if (!files) return @@ -343,6 +366,7 @@ export function ChatInput() { const handleDrop = (event: React.DragEvent) => { event.preventDefault() + if (isMemberSession) return const files = event.dataTransfer.files if (files.length > 0) { const fakeEvent = { target: { files } } as React.ChangeEvent @@ -355,6 +379,7 @@ export function ChatInput() { } const insertSlashCommand = () => { + if (isMemberSession) return const el = textareaRef.current const cursorPos = el?.selectionStart ?? input.length const replacement = replaceSlashToken(input, cursorPos, '', { trailingSpace: false }) @@ -377,7 +402,7 @@ export function ChatInput() { onDragOver={(event) => event.preventDefault()} onDrop={handleDrop} > - {fileSearchOpen && ( + {!isMemberSession && fileSearchOpen && ( )} - {slashMenuOpen && filteredCommands.length > 0 && ( + {!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
-
- + {!isMemberSession && ( + <> +
+ - {plusMenuOpen && ( -
- - + {plusMenuOpen && ( +
+ + +
+ )}
- )} -
- + + + )}
- + {!isMemberSession && }
@@ -522,33 +553,33 @@ export function ChatInput() { -
- {hasMessages ? ( - - ) : ( - { - if (!activeTabId) return - const oldId = activeTabId - const { deleteSession, createSession } = useSessionStore.getState() - const { replaceTabSession } = useTabStore.getState() - const { disconnectSession, connectToSession } = useChatStore.getState() - // Create new session first, then swap tab in-place - const newId = await createSession(newWorkDir) - disconnectSession(oldId) - replaceTabSession(oldId, newId) - connectToSession(newId) - // Clean up old session in background - deleteSession(oldId).catch(() => {}) - }} - /> - )} -
+ {!isMemberSession && ( +
+ {hasMessages ? ( + + ) : ( + { + if (!activeTabId) return + const oldId = activeTabId + const { deleteSession, createSession } = useSessionStore.getState() + const { replaceTabSession } = useTabStore.getState() + const { disconnectSession, connectToSession } = useChatStore.getState() + const newId = await createSession(newWorkDir) + disconnectSession(oldId) + replaceTabSession(oldId, newId) + connectToSession(newId) + deleteSession(oldId).catch(() => {}) + }} + /> + )} +
+ )}
) diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index d13c5c56..4ee13c6b 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -160,7 +160,7 @@ export function MessageList() { ) } -const MessageBlock = memo(function MessageBlock({ +export const MessageBlock = memo(function MessageBlock({ message, activeThinkingId, agentTaskNotifications, diff --git a/desktop/src/components/chat/ToolCallGroup.tsx b/desktop/src/components/chat/ToolCallGroup.tsx index 8b712ba2..f63b82a2 100644 --- a/desktop/src/components/chat/ToolCallGroup.tsx +++ b/desktop/src/components/chat/ToolCallGroup.tsx @@ -5,6 +5,7 @@ import { Modal } from '../shared/Modal' import { useTranslation } from '../../i18n' import type { TranslationKey } from '../../i18n' import type { AgentTaskNotification, UIMessage } from '../../types/chat' +import { AGENT_LIFECYCLE_TYPES } from '../../types/team' type ToolCall = Extract type ToolResult = Extract @@ -286,7 +287,7 @@ function AgentCallCard({ ? getAgentErrorSummary(result.content) : '' const fullOutputText = - result && !result.isError && !isLaunchResult + result && !result.isError && !isLaunchResult && !isAgentLifecycleResult(result.content) ? extractTextContent(result.content).trim() : '' const previewText = fullOutputText || (status === 'done' || status === 'stopped' ? taskSummary : '') @@ -306,7 +307,12 @@ function AgentCallCard({ )} - {!expanded && recentToolCalls.length > 0 && ( + {!expanded && outputSummary && ( +
+ {outputSummary} +
+ )} + {!expanded && !outputSummary && recentToolCalls.length > 0 && (
{recentToolCalls.map((recentToolCall) => (
)} - {!expanded && !recentToolCalls.length && errorText && ( + {!expanded && !outputSummary && !recentToolCalls.length && errorText && (
{errorText}
)} - {!expanded && !recentToolCalls.length && !errorText && outputSummary && ( -
- {outputSummary} -
- )}
{outputSummary && ( - ) -} diff --git a/desktop/src/components/teams/MemberTag.tsx b/desktop/src/components/teams/MemberTag.tsx deleted file mode 100644 index 48cdc45b..00000000 --- a/desktop/src/components/teams/MemberTag.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useTeamStore } from '../../stores/teamStore' -import type { TeamMember, AgentColor } from '../../types/team' - -const COLOR_MAP: Record = { - red: 'var(--color-agent-red)', - blue: 'var(--color-agent-blue)', - green: 'var(--color-agent-green)', - yellow: 'var(--color-agent-yellow)', - purple: 'var(--color-agent-purple)', - orange: 'var(--color-agent-orange)', - pink: 'var(--color-agent-pink)', - cyan: 'var(--color-agent-cyan)', -} - -const STATUS_STYLES: Record = { - running: { dotClass: 'animate-pulse-dot', color: 'var(--color-warning)' }, - completed: { dotClass: '', color: 'var(--color-success)' }, - error: { dotClass: '', color: 'var(--color-error)' }, - idle: { dotClass: '', color: 'var(--color-text-tertiary)' }, -} - -export function MemberTag({ member }: { member: TeamMember }) { - const { setViewingAgent, viewingAgentId, memberColors } = useTeamStore() - const isViewing = viewingAgentId === member.agentId - const color = member.color || memberColors.get(member.agentId) || 'blue' - const agentColor = COLOR_MAP[color] - const statusInfo = STATUS_STYLES[member.status] || STATUS_STYLES.idle! - - return ( - - ) -} diff --git a/desktop/src/components/teams/TeamStatusBar.tsx b/desktop/src/components/teams/TeamStatusBar.tsx index 23769b8d..10858a5e 100644 --- a/desktop/src/components/teams/TeamStatusBar.tsx +++ b/desktop/src/components/teams/TeamStatusBar.tsx @@ -1,31 +1,147 @@ +import { useState } from 'react' import { useTeamStore } from '../../stores/teamStore' -import { MemberTag } from './MemberTag' import { useTranslation } from '../../i18n' +import type { TeamMember } from '../../types/team' + +const memberStatusConfig = { + running: { + icon: 'pending', + color: 'var(--color-warning)', + pulse: true, + }, + idle: { + icon: 'radio_button_unchecked', + color: 'var(--color-text-tertiary)', + pulse: false, + }, + completed: { + icon: 'check_circle', + color: 'var(--color-success)', + pulse: false, + }, + error: { + icon: 'error', + color: 'var(--color-error)', + pulse: false, + }, +} as const export function TeamStatusBar() { const t = useTranslation() - const { activeTeam } = useTeamStore() + const { activeTeam, openMemberSession } = useTeamStore() + const [expanded, setExpanded] = useState(true) if (!activeTeam) return null - return ( -
- {/* Team header */} -
- - {t('teams.team')} {activeTeam.name} - - - ({activeTeam.members.length} {t('teams.members')}) - -
+ // Filter out leader — main window is already the leader's view + const members = activeTeam.members.filter( + (m) => !activeTeam.leadAgentId || m.agentId !== activeTeam.leadAgentId, + ) + const runningCount = members.filter((m) => m.status === 'running').length + const completedCount = members.filter((m) => m.status === 'completed').length + const totalCount = members.length + const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0 + const allDone = runningCount === 0 && totalCount > 0 - {/* Member tags */} -
- {activeTeam.members.map((member) => ( - - ))} + return ( +
+
+ {/* Header */} + + + {/* Expanded member list */} + {expanded && ( +
+ {members.map((member) => ( + openMemberSession(member)} /> + ))} +
+ )}
) } + +function MemberRow({ member, onView }: { member: TeamMember; onView: () => void }) { + const config = memberStatusConfig[member.status] || memberStatusConfig.idle + + return ( + + ) +} diff --git a/desktop/src/components/teams/TranscriptView.tsx b/desktop/src/components/teams/TranscriptView.tsx deleted file mode 100644 index 63a1129d..00000000 --- a/desktop/src/components/teams/TranscriptView.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useTeamStore } from '../../stores/teamStore' -import { useTranslation } from '../../i18n' - -export function TranscriptView() { - const t = useTranslation() - const { agentTranscript, viewingAgentId, activeTeam } = useTeamStore() - const member = activeTeam?.members.find((m) => m.agentId === viewingAgentId) - - return ( -
-
- {/* Viewing divider */} -
-
- - {t('teams.viewing')} {member?.role || viewingAgentId} {t('teams.transcript')} - -
-
- - {/* Transcript messages */} - {agentTranscript.length === 0 ? ( -
- {t('teams.noMessages')} -
- ) : ( - agentTranscript.map((msg) => ( -
-
-                {typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)}
-              
-
- )) - )} -
-
- ) -} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1b222e73..0ce41e71 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -5,6 +5,7 @@ export const en = { 'common.delete': 'Delete', 'common.add': 'Add', 'common.run': 'Run', + 'common.send': 'Send', 'common.stop': 'Stop', 'common.rename': 'Rename', 'common.retry': 'Retry', @@ -489,6 +490,12 @@ export const en = { 'teams.noMessages': 'No messages yet', 'teams.team': 'Team:', 'teams.members': 'members', + 'teams.working': 'Working...', + 'teams.thinking': 'Thinking...', + 'teams.running': 'running', + 'teams.leader': 'team-lead', + 'teams.memberPlaceholder': 'Message this teammate directly...', + 'teams.memberSessionHint': 'Messages here go straight to this teammate while you watch its transcript update.', // ─── Scheduled Tasks Pages ────────────────────────────────────── 'scheduledPage.title': 'Scheduled tasks', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 6c6f58a4..548a64cf 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -7,6 +7,7 @@ export const zh: Record = { 'common.delete': '删除', 'common.add': '添加', 'common.run': '运行', + 'common.send': '发送', 'common.stop': '停止', 'common.rename': '重命名', 'common.retry': '重试', @@ -491,6 +492,12 @@ export const zh: Record = { 'teams.noMessages': '暂无消息', 'teams.team': '团队:', 'teams.members': '名成员', + 'teams.working': '正在工作中...', + 'teams.thinking': '正在思考中...', + 'teams.running': '运行中', + 'teams.leader': '主控', + 'teams.memberPlaceholder': '直接给这个 Agent 发消息...', + 'teams.memberSessionHint': '这里发送的消息会直接投递给该 Agent,同时继续刷新它的 transcript。', // ─── Scheduled Tasks Pages ────────────────────────────────────── 'scheduledPage.title': '定时任务', diff --git a/desktop/src/pages/ActiveSession.test.tsx b/desktop/src/pages/ActiveSession.test.tsx index bc5faf52..a4100d13 100644 --- a/desktop/src/pages/ActiveSession.test.tsx +++ b/desktop/src/pages/ActiveSession.test.tsx @@ -24,12 +24,14 @@ import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' import { useSessionStore } from '../stores/sessionStore' import { useTabStore } from '../stores/tabStore' +import { useTeamStore } from '../stores/teamStore' afterEach(() => { vi.useRealTimers() useTabStore.setState({ tabs: [], activeTabId: null }) useSessionStore.setState({ sessions: [], activeSessionId: null, isLoading: false, error: null }) useChatStore.setState({ sessions: {} }) + useTeamStore.setState({ teams: [], activeTeam: null, memberColors: new Map(), error: null }) }) describe('ActiveSession task polling', () => { @@ -102,4 +104,76 @@ describe('ActiveSession task polling', () => { unmount() useCLITaskStore.setState(originalCliTaskState) }) + + it('keeps member sessions interactive and skips leader task polling', () => { + const memberSessionId = 'team-member:security-reviewer@test-team' + const originalCliTaskState = useCLITaskStore.getState() + const fetchSessionTasks = vi.fn().mockResolvedValue(undefined) + + useCLITaskStore.setState({ + sessionId: null, + tasks: [], + fetchSessionTasks, + }) + + useTeamStore.setState({ + teams: [], + activeTeam: { + name: 'test-team', + leadAgentId: 'team-lead@test-team', + leadSessionId: 'leader-session', + members: [ + { + agentId: 'team-lead@test-team', + role: 'team-lead', + status: 'running', + sessionId: 'leader-session', + }, + { + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + }, + ], + }, + memberColors: new Map(), + error: null, + }) + + useTabStore.setState({ + tabs: [{ sessionId: memberSessionId, title: 'security-reviewer', type: 'session', status: 'idle' }], + activeTabId: memberSessionId, + }) + + useChatStore.setState({ + sessions: { + [memberSessionId]: { + messages: [], + chatState: 'thinking', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + const { queryByTestId, unmount } = render() + + expect(queryByTestId('chat-input')).toBeInTheDocument() + expect(queryByTestId('session-task-bar')).not.toBeInTheDocument() + expect(fetchSessionTasks).not.toHaveBeenCalled() + + unmount() + useCLITaskStore.setState(originalCliTaskState) + }) }) diff --git a/desktop/src/pages/ActiveSession.tsx b/desktop/src/pages/ActiveSession.tsx index 6b2907d9..7e8515f4 100644 --- a/desktop/src/pages/ActiveSession.tsx +++ b/desktop/src/pages/ActiveSession.tsx @@ -3,6 +3,7 @@ import { useTabStore } from '../stores/tabStore' import { useSessionStore } from '../stores/sessionStore' import { useChatStore } from '../stores/chatStore' import { useCLITaskStore } from '../stores/cliTaskStore' +import { useTeamStore } from '../stores/teamStore' import { useTranslation } from '../i18n' import { MessageList } from '../components/chat/MessageList' import { ChatInput } from '../components/chat/ChatInput' @@ -23,15 +24,18 @@ export function ActiveSession() { const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 } const session = sessions.find((s) => s.id === activeTabId) + const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null) + const activeTeam = useTeamStore((s) => s.activeTeam) + const isMemberSession = !!memberInfo useEffect(() => { - if (activeTabId) { + if (activeTabId && !isMemberSession) { connectToSession(activeTabId) } - }, [activeTabId, connectToSession]) + }, [activeTabId, isMemberSession, connectToSession]) useEffect(() => { - if (!activeTabId) return + if (!activeTabId || isMemberSession) return const shouldPollTasks = chatState !== 'idle' || @@ -48,6 +52,7 @@ export function ActiveSession() { return () => clearInterval(timer) }, [ activeTabId, + isMemberSession, chatState, trackedTaskSessionId, hasIncompleteTasks, @@ -75,76 +80,130 @@ export function ActiveSession() { return (
+ {isMemberSession && ( +
+
+
+
+ {memberInfo?.status === 'running' && ( + + )} + {memberInfo?.status === 'completed' && ( + check_circle + )} + smart_toy + + {memberInfo?.role} + + {activeTeam && ( + + @ {activeTeam.name} + + )} +
+

+ {t('teams.memberSessionHint')} +

+
+ +
+
+ )} + {isEmpty ? ( - /* Welcome hero — same look as EmptySession */
- Claude Code Haha -

- {t('empty.title')} -

-

- {t('empty.subtitle')} -

+ {isMemberSession ? ( + <> + smart_toy +

+ {memberInfo?.status === 'running' + ? `${memberInfo.role} ${t('teams.working')}` + : t('teams.noMessages')} +

+ + ) : ( + <> + Claude Code Haha +

+ {t('empty.title')} +

+

+ {t('empty.subtitle')} +

+ + )}
) : ( <> - {/* Session info header */} -
-
-

- {session?.title || t('session.untitled')} -

-
- {isActive && ( - - - {t('session.active')} - - )} - {totalTokens > 0 && ( - <> - · - {totalTokens.toLocaleString()} t - - )} - {lastUpdated && ( - <> - · - {t('session.lastUpdated', { time: lastUpdated })} - - )} - {session?.messageCount !== undefined && session.messageCount > 0 && ( - <> - · - {t('session.messages', { count: session.messageCount })} - + {!isMemberSession && ( +
+
+

+ {session?.title || t('session.untitled')} +

+
+ {isActive && ( + + + {t('session.active')} + + )} + {totalTokens > 0 && ( + <> + · + {totalTokens.toLocaleString()} t + + )} + {lastUpdated && ( + <> + · + {t('session.lastUpdated', { time: lastUpdated })} + + )} + {session?.messageCount !== undefined && session.messageCount > 0 && ( + <> + · + {t('session.messages', { count: session.messageCount })} + + )} +
+ {session?.workDirExists === false && ( +
+ warning + + {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} + +
)}
- {session?.workDirExists === false && ( -
- warning - - {t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })} - -
- )}
-
+ )} - {/* Message stream */} )} - {/* Session task bar — sticky at bottom */} - + {!isMemberSession && } - {/* Team status bar */} - {/* Chat input */}
) diff --git a/desktop/src/pages/AgentTranscript.tsx b/desktop/src/pages/AgentTranscript.tsx deleted file mode 100644 index 97686413..00000000 --- a/desktop/src/pages/AgentTranscript.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useEffect } from 'react' -import { useTeamStore } from '../stores/teamStore' -import { mockTranscript } from '../mocks/data' - -function Icon({ name, className = '', filled = false }: { name: string; className?: string; filled?: boolean }) { - return ( - - {name} - - ) -} - -export function AgentTranscript() { - const { viewingAgentId, activeTeam, agentTranscript, setViewingAgent, fetchMemberTranscript } = useTeamStore() - - const viewingMember = activeTeam?.members.find((m) => m.agentId === viewingAgentId) - const agentName = viewingMember?.role ?? mockTranscript.agentName - - useEffect(() => { - if (viewingAgentId && activeTeam) { - fetchMemberTranscript(activeTeam.name, viewingAgentId) - } - }, [viewingAgentId, activeTeam, fetchMemberTranscript]) - - // Use real transcript if available, fall back to mock - const messages = agentTranscript.length > 0 - ? agentTranscript.map((msg, i) => ({ - id: msg.id || `t-${i}`, - role: 'agent' as const, - content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content), - timestamp: msg.timestamp, - })) - : mockTranscript.messages - - const teamMembers = activeTeam - ? activeTeam.members.map((m) => ({ - id: m.agentId, - role: m.role, - active: m.agentId === viewingAgentId, - })) - : mockTranscript.teamBar - - return ( -
- {/* Context Banner */} -
-
- - - Viewing: {agentName} transcript - -
- -
- - {/* Teammate Transcript Stream */} -
- {messages.map((msg) => { - if (msg.role === 'agent') { - return ( -
-
- -
-
-
- {agentName} - {msg.timestamp} -
-
-

{msg.content}

-
-
-
- ) - } - - if (msg.role === 'tool') { - return ( -
-
- -
-
-
- Tool Call: {msg.toolName?.toLowerCase()} - - {msg.status} - -
-
-
-
-
-
- - ~/projects/companion/src/components - -
-
-                      {msg.command}
-                    
-
-
-
- ) - } - - if (msg.role === 'progress') { - return ( -
-
- -
-
-
- -
-

{msg.label}

-
-
-
-
- {msg.progress}% -
-
-
- ) - } - - return null - })} -
- - {/* ── Team Strip (Persistent Navigation) ── */} -
-
- The Team -
-
- {teamMembers.map((member) => ( - - ))} -
-
- Teammate is thinking... - -
-
-
- ) -} diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 81ff26cc..adb56acf 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -1,8 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { MessageEntry } from '../types/session' -const { sendMock } = vi.hoisted(() => ({ +const { + sendMock, + getMemberBySessionIdMock, + sendMessageToMemberMock, + handleTeamCreatedMock, + handleTeamUpdateMock, + handleTeamDeletedMock, +} = vi.hoisted(() => ({ sendMock: vi.fn(), + getMemberBySessionIdMock: vi.fn<(sessionId: string) => any>(() => null), + sendMessageToMemberMock: vi.fn(async () => {}), + handleTeamCreatedMock: vi.fn(), + handleTeamUpdateMock: vi.fn(), + handleTeamDeletedMock: vi.fn(), })) vi.mock('../api/websocket', () => ({ @@ -25,9 +37,11 @@ vi.mock('../api/sessions', () => ({ vi.mock('./teamStore', () => ({ useTeamStore: { getState: () => ({ - handleTeamCreated: vi.fn(), - handleTeamUpdate: vi.fn(), - handleTeamDeleted: vi.fn(), + getMemberBySessionId: getMemberBySessionIdMock, + sendMessageToMember: sendMessageToMemberMock, + handleTeamCreated: handleTeamCreatedMock, + handleTeamUpdate: handleTeamUpdateMock, + handleTeamDeleted: handleTeamDeletedMock, }), }, })) @@ -70,6 +84,9 @@ const initialState = useChatStore.getState() describe('chatStore history mapping', () => { beforeEach(() => { sendMock.mockReset() + getMemberBySessionIdMock.mockReset() + getMemberBySessionIdMock.mockReturnValue(null) + sendMessageToMemberMock.mockReset() useChatStore.setState({ ...initialState, sessions: {}, @@ -113,6 +130,28 @@ describe('chatStore history mapping', () => { expect(mapped[3]).toMatchObject({ parentToolUseId: 'agent-1' }) }) + it('surfaces teammate prompt content when mapping member transcript history', () => { + const messages: MessageEntry[] = [ + { + id: 'user-1', + type: 'user', + timestamp: '2026-04-06T00:00:00.000Z', + content: 'Review the auth diff and call out risks.', + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages, { + includeTeammateMessages: true, + }) + + expect(mapped).toMatchObject([ + { + type: 'user_text', + content: 'Review the auth diff and call out risks.', + }, + ]) + }) + it('keeps parent tool linkage for live tool events', () => { // Initialize the session first useChatStore.setState({ @@ -247,4 +286,51 @@ describe('chatStore history mapping', () => { outputFile: '/tmp/agent-output.txt', }) }) + + it('routes member-session messages through team mailbox delivery instead of websocket', async () => { + const memberSessionId = 'team-member:security-reviewer@test-team' + getMemberBySessionIdMock.mockReturnValue({ + agentId: 'security-reviewer@test-team', + role: 'security-reviewer', + status: 'running', + }) + + useChatStore.setState({ + sessions: { + [memberSessionId]: { + messages: [], + chatState: 'idle', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + useChatStore.getState().sendMessage(memberSessionId, 'Check the latest regression') + await Promise.resolve() + + expect(sendMessageToMemberMock).toHaveBeenCalledWith( + memberSessionId, + 'Check the latest regression', + ) + expect(sendMock).not.toHaveBeenCalled() + const sessionMessages = useChatStore.getState().sessions[memberSessionId]?.messages ?? [] + + expect(sessionMessages[sessionMessages.length - 1]).toMatchObject({ + type: 'user_text', + content: 'Check the latest regression', + pending: true, + }) + }) }) diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 5dbc627f..0b971a66 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -6,6 +6,7 @@ import { useSessionStore } from './sessionStore' import { useCLITaskStore } from './cliTaskStore' import { useTabStore } from './tabStore' import { randomSpinnerVerb } from '../config/spinnerVerbs' +import { AGENT_LIFECYCLE_TYPES } from '../types/team' import type { MessageEntry } from '../types/session' import type { PermissionMode } from '../types/settings' import type { @@ -163,6 +164,7 @@ export const useChatStore = create((set, get) => ({ sendMessage: (sessionId, content, attachments?) => { const userFacingContent = content.trim() + const isMemberSession = !!useTeamStore.getState().getMemberBySessionId(sessionId) const uiAttachments: UIAttachment[] | undefined = attachments && attachments.length > 0 ? attachments.map((a) => ({ @@ -177,11 +179,10 @@ export const useChatStore = create((set, get) => ({ const allTasksDone = taskStore.tasks.length > 0 && taskStore.tasks.every((t) => t.status === 'completed') set((s) => { - const session = s.sessions[sessionId] - if (!session) return s + const session = s.sessions[sessionId] ?? createDefaultSessionState() const newMessages = [...session.messages] - if (allTasksDone) { + if (!isMemberSession && allTasksDone) { newMessages.push({ id: nextId(), type: 'task_summary', @@ -194,15 +195,18 @@ export const useChatStore = create((set, get) => ({ id: nextId(), type: 'user_text', content: userFacingContent, - attachments: uiAttachments, + attachments: isMemberSession ? undefined : uiAttachments, timestamp: Date.now(), + ...(isMemberSession ? { pending: true } : {}), }) - if (session.elapsedTimer) clearInterval(session.elapsedTimer) + if (!isMemberSession && session.elapsedTimer) clearInterval(session.elapsedTimer) - const timer = setInterval(() => { - set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) - }, 1000) + const timer = !isMemberSession + ? setInterval(() => { + set((st) => ({ sessions: updateSessionIn(st.sessions, sessionId, (sess) => ({ elapsedSeconds: sess.elapsedSeconds + 1 })) })) + }, 1000) + : null return { sessions: { @@ -213,13 +217,36 @@ export const useChatStore = create((set, get) => ({ chatState: 'thinking', elapsedSeconds: 0, streamingText: '', - statusVerb: randomSpinnerVerb(), + statusVerb: isMemberSession ? '' : randomSpinnerVerb(), elapsedTimer: timer, + connectionState: isMemberSession ? 'connected' : session.connectionState, }, }, } }) + if (isMemberSession) { + void useTeamStore.getState().sendMessageToMember(sessionId, userFacingContent) + .catch((err) => { + set((s) => ({ + sessions: updateSessionIn(s.sessions, sessionId, (session) => ({ + chatState: 'idle', + messages: [ + ...session.messages, + { + id: nextId(), + type: 'error', + message: err instanceof Error ? err.message : String(err), + code: 'TEAM_MEMBER_MESSAGE_FAILED', + timestamp: Date.now(), + }, + ], + })), + })) + }) + return + } + wsManager.send(sessionId, { type: 'user_message', content, attachments }) }, @@ -253,10 +280,14 @@ export const useChatStore = create((set, get) => ({ try { const { messages } = await sessionsApi.getMessages(sessionId) const uiMessages = mapHistoryMessagesToUiMessages(messages) + const restoredNotifications = reconstructAgentNotifications(messages) set((state) => { const session = state.sessions[sessionId] if (!session || session.messages.length > 0) return state - return { sessions: updateSessionIn(state.sessions, sessionId, () => ({ messages: uiMessages })) } + return { sessions: updateSessionIn(state.sessions, sessionId, (s) => ({ + messages: uiMessages, + agentTaskNotifications: { ...s.agentTaskNotifications, ...restoredNotifications }, + })) } }) const lastTodos = extractLastTodoWriteFromHistory(messages) if (lastTodos && lastTodos.length > 0) { @@ -519,11 +550,140 @@ export const useChatStore = create((set, get) => ({ type AssistantHistoryBlock = { type: string; text?: string; thinking?: string; name?: string; id?: string; input?: unknown } type UserHistoryBlock = { type: string; text?: string; tool_use_id?: string; content?: unknown; is_error?: boolean; source?: { data?: string }; mimeType?: string; media_type?: string; name?: string } -export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMessage[] { +/** + * Check if text is a teammate-message (internal agent-to-agent communication). + * Uses full open+close tag match to avoid false positives on user text + * that merely mentions the tag name (e.g., pasting code or discussing the protocol). + */ +function isTeammateMessage(text: string): boolean { + return text.includes('') +} + +const TEAMMATE_CONTENT_REGEX = /]*>\n?([\s\S]*?)\n?<\/teammate-message>/g + +function extractVisibleTeammateMessageContents(text: string): string[] { + const contents: string[] = [] + + for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) { + const content = match[2]?.trim() + if (!content) continue + + if (content.startsWith('{') && content.endsWith('}')) { + try { + const parsed = JSON.parse(content) as Record + if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) { + continue + } + } catch { + // Keep non-JSON payloads that happen to look like JSON. + } + } + + contents.push(content) + } + + return contents +} + +type HistoryMappingOptions = { + includeTeammateMessages?: boolean +} + +/** + * Reconstruct agentTaskNotifications from history. + * + * During a live session, background agents report completion via system_notification + * events (task_notification). These are NOT persisted in JSONL history. On reload, + * we reconstruct them by correlating Agent tool_use names with + * teammate_ids found in subsequent user messages. + */ +export function reconstructAgentNotifications(messages: MessageEntry[]): Record { + // Step 1: Collect Agent tool_use blocks → map agent name to toolUseId + const agentNameToToolUseId = new Map() + + for (const msg of messages) { + if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) { + for (const block of msg.content as AssistantHistoryBlock[]) { + if (block.type === 'tool_use' && block.name === 'Agent' && block.id) { + const input = block.input as Record | undefined + const name = input?.name as string | undefined + // Keep first toolUseId per name (consistent with first-wins for teammateContent) + if (name && !agentNameToToolUseId.has(name)) agentNameToToolUseId.set(name, block.id) + } + } + } + } + + if (agentNameToToolUseId.size === 0) return {} + + // Step 2: Extract content by teammate_id + // Skip lifecycle messages (shutdown_approved, idle_notification, etc.) + // which overwrite actual review content if stored later in history + const teammateContent = new Map() + for (const msg of messages) { + if (msg.type !== 'user') continue + const text = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? (msg.content as Array<{ type?: string; text?: string }>).filter((b) => b.type === 'text' && b.text).map((b) => b.text).join('\n') + : '' + if (!text.includes(' + if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) continue + } catch { /* not JSON, keep it */ } + } + // Only store the first meaningful content per teammate (avoid overwrite by later lifecycle msgs) + if (!teammateContent.has(match[1])) { + teammateContent.set(match[1], content) + } + } + } + } + + // Step 3: Correlate and build notifications + const notifications: Record = {} + for (const [name, toolUseId] of agentNameToToolUseId) { + const content = teammateContent.get(name) + if (content) { + notifications[toolUseId] = { + taskId: toolUseId, + toolUseId, + status: 'completed', + summary: content, + } + } + } + + return notifications +} + +export function mapHistoryMessagesToUiMessages( + messages: MessageEntry[], + options?: HistoryMappingOptions, +): UIMessage[] { + const includeTeammateMessages = options?.includeTeammateMessages === true const uiMessages: UIMessage[] = [] for (const msg of messages) { const timestamp = new Date(msg.timestamp).getTime() if (msg.type === 'user' && typeof msg.content === 'string') { + if (isTeammateMessage(msg.content)) { + if (!includeTeammateMessages) continue + const teammateContents = extractVisibleTeammateMessageContents(msg.content) + if (teammateContents.length === 0) continue + uiMessages.push({ + id: msg.id || nextId(), + type: 'user_text', + content: teammateContents.join('\n\n'), + timestamp, + }) + continue + } uiMessages.push({ id: msg.id || nextId(), type: 'user_text', content: msg.content, timestamp }) continue } @@ -543,7 +703,12 @@ export function mapHistoryMessagesToUiMessages(messages: MessageEntry[]): UIMess const textParts: string[] = [] const attachments: UIAttachment[] = [] for (const block of msg.content as UserHistoryBlock[]) { - if (block.type === 'text' && block.text) textParts.push(block.text) + if (block.type === 'text' && block.text && isTeammateMessage(block.text)) { + if (!includeTeammateMessages) continue + textParts.push(...extractVisibleTeammateMessageContents(block.text)) + } else if (block.type === 'text' && block.text) { + textParts.push(block.text) + } else if (block.type === 'image') attachments.push({ type: 'image', name: block.name || 'image', data: block.source?.data, mimeType: block.mimeType || block.media_type }) else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' }) else if (block.type === 'tool_result') uiMessages.push({ id: nextId(), type: 'tool_result', toolUseId: block.tool_use_id ?? '', content: block.content, isError: !!block.is_error, timestamp, parentToolUseId: msg.parentToolUseId }) diff --git a/desktop/src/stores/teamStore.ts b/desktop/src/stores/teamStore.ts index 3ad9a04f..93e0b574 100644 --- a/desktop/src/stores/teamStore.ts +++ b/desktop/src/stores/teamStore.ts @@ -1,21 +1,134 @@ import { create } from 'zustand' import { teamsApi } from '../api/teams' -import type { TeamSummary, TeamDetail, TeamMember, TranscriptMessage, AgentColor } from '../types/team' +import type { TeamSummary, TeamDetail, TeamMember, AgentColor } from '../types/team' import { AGENT_COLORS } from '../types/team' -import type { TeamMemberStatus } from '../types/chat' +import type { TeamMemberStatus, UIMessage } from '../types/chat' +import { useChatStore, mapHistoryMessagesToUiMessages } from './chatStore' +import { useTabStore } from './tabStore' + +const MEMBER_POLL_INTERVAL_MS = 1500 +const MEMBER_TRANSCRIPT_MATCH_WINDOW_MS = 120_000 + +/** Generate a synthetic sessionId for team member tabs */ +const memberSessionId = (agentId: string) => `team-member:${agentId}` + +/** Module-level timer for polling member transcript */ +let memberPollTimer: ReturnType | null = null +let polledMemberSessionId: string | null = null + +function createMemberSessionState() { + return { + messages: [] as UIMessage[], + chatState: 'idle' as const, + connectionState: 'connected' as const, + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + } +} + +function normalizeMemberStatus(status: string | undefined): TeamMember['status'] { + if (status === 'running' || status === 'idle' || status === 'completed') { + return status + } + return status === 'failed' ? 'error' : 'idle' +} + +function toTeamMember(raw: Record): TeamMember { + return { + agentId: (raw.agentId as string) || '', + name: raw.name as string | undefined, + role: + (raw.name as string) || + (raw.agentType as string) || + (raw.role as string) || + (raw.agentId as string) || + '', + status: normalizeMemberStatus(raw.status as string | undefined), + currentTask: raw.currentTask as string | undefined, + color: raw.color as AgentColor | undefined, + sessionId: raw.sessionId as string | undefined, + } +} + +function isPendingMemberMessage(message: UIMessage): message is Extract & { pending: true } { + return message.type === 'user_text' && message.pending === true +} + +function transcriptAlreadyContainsMessage( + transcriptMessages: UIMessage[], + pendingMessage: Extract & { pending: true }, +): boolean { + return transcriptMessages.some((message) => ( + message.type === 'user_text' && + message.pending !== true && + message.content === pendingMessage.content && + Math.abs(message.timestamp - pendingMessage.timestamp) <= MEMBER_TRANSCRIPT_MATCH_WINDOW_MS + )) +} + +function mergeMemberTranscriptMessages( + existingMessages: UIMessage[], + transcriptMessages: UIMessage[], +): UIMessage[] { + const pendingMessages = existingMessages.filter(isPendingMemberMessage).filter( + (message) => !transcriptAlreadyContainsMessage(transcriptMessages, message), + ) + + return pendingMessages.length > 0 + ? [...transcriptMessages, ...pendingMessages] + : transcriptMessages +} + +function syncMemberSessionMessages( + sessionId: string, + memberStatus: TeamMember['status'], + messages: UIMessage[], +) { + const hasPendingMessages = messages.some(isPendingMemberMessage) + useChatStore.setState((state) => { + const existing = state.sessions[sessionId] + const nextState = existing ?? createMemberSessionState() + return { + sessions: { + ...state.sessions, + [sessionId]: { + ...nextState, + messages, + connectionState: 'connected', + chatState: + memberStatus === 'running' || hasPendingMessages + ? 'thinking' + : 'idle', + }, + }, + } + }) +} type TeamStore = { teams: TeamSummary[] activeTeam: TeamDetail | null - viewingAgentId: string | null - agentTranscript: TranscriptMessage[] memberColors: Map error: string | null fetchTeams: () => Promise fetchTeamDetail: (name: string) => Promise - fetchMemberTranscript: (teamName: string, agentId: string) => Promise - setViewingAgent: (agentId: string | null) => void + getMemberBySessionId: (sessionId: string) => TeamMember | null + refreshMemberSession: (sessionId: string) => Promise + openMemberSession: (member: TeamMember) => void + sendMessageToMember: (sessionId: string, content: string) => Promise + startMemberPolling: (sessionId: string, force?: boolean) => void + stopMemberPolling: () => void clearTeam: () => void // WebSocket handlers @@ -27,8 +140,6 @@ type TeamStore = { export const useTeamStore = create((set, get) => ({ teams: [], activeTeam: null, - viewingAgentId: null, - agentTranscript: [], memberColors: new Map(), error: null, @@ -45,7 +156,16 @@ export const useTeamStore = create((set, get) => ({ fetchTeamDetail: async (name: string) => { set({ error: null }) try { - const detail = await teamsApi.get(name) + const raw = await teamsApi.get(name) as Record + const rawMembers = Array.isArray(raw.members) ? raw.members : [] + const members: TeamMember[] = rawMembers.map((m: Record) => toTeamMember(m)) + const detail: TeamDetail = { + name: raw.name as string, + leadAgentId: raw.leadAgentId as string | undefined, + leadSessionId: raw.leadSessionId as string | undefined, + members, + createdAt: raw.createdAt != null ? String(raw.createdAt) : undefined, + } // Assign colors to members const colors = new Map() detail.members.forEach((m, i) => { @@ -57,52 +177,164 @@ export const useTeamStore = create((set, get) => ({ } }, - fetchMemberTranscript: async (teamName: string, agentId: string) => { - set({ error: null }) + getMemberBySessionId: (sessionId: string) => { + const team = get().activeTeam + if (!team) return null + return team.members.find( + (m) => m.sessionId === sessionId || memberSessionId(m.agentId) === sessionId, + ) ?? null + }, + + refreshMemberSession: async (sessionId) => { + const team = get().activeTeam + const member = get().getMemberBySessionId(sessionId) + if (!team || !member) return + try { - const { messages } = await teamsApi.getMemberTranscript(teamName, agentId) - set({ agentTranscript: messages, viewingAgentId: agentId }) - } catch (err) { - set({ error: err instanceof Error ? err.message : String(err) }) + const { messages } = await teamsApi.getMemberTranscript(team.name, member.agentId) + const asEntries = messages.map((msg) => ({ + id: msg.id, + type: msg.type, + content: msg.content, + timestamp: msg.timestamp, + model: msg.model, + parentToolUseId: msg.parentToolUseId, + })) + const transcriptMessages = mapHistoryMessagesToUiMessages( + asEntries as Parameters[0], + { includeTeammateMessages: true }, + ) + const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + const mergedMessages = mergeMemberTranscriptMessages( + existingMessages, + transcriptMessages, + ) + syncMemberSessionMessages(sessionId, member.status, mergedMessages) + } catch { + const existingMessages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + syncMemberSessionMessages(sessionId, member.status, existingMessages) } }, - setViewingAgent: (agentId) => { - if (!agentId) { - set({ viewingAgentId: null, agentTranscript: [] }) - } else { - set({ viewingAgentId: agentId }) - // Fetch transcript if we have an active team - const team = get().activeTeam - if (team) { - get().fetchMemberTranscript(team.name, agentId) + openMemberSession: (member: TeamMember) => { + const team = get().activeTeam + if (!team) return + + get().stopMemberPolling() + + const tabId = memberSessionId(member.agentId) + useTabStore.getState().openTab(tabId, member.role, 'session') + void get().refreshMemberSession(tabId) + get().startMemberPolling(tabId) + }, + + sendMessageToMember: async (sessionId, content) => { + const team = get().activeTeam + const member = get().getMemberBySessionId(sessionId) + if (!team || !member) { + throw new Error('Team member session is no longer available') + } + + await teamsApi.sendMemberMessage(team.name, member.agentId, content) + get().startMemberPolling(sessionId, true) + await get().refreshMemberSession(sessionId) + }, + + startMemberPolling: (sessionId, force = false) => { + const member = get().getMemberBySessionId(sessionId) + if (!member) return + + const hasPendingMessages = + useChatStore.getState().sessions[sessionId]?.messages.some(isPendingMemberMessage) ?? false + + if (!force && polledMemberSessionId === sessionId && memberPollTimer) { + return + } + + if (member.status !== 'running' && !hasPendingMessages) { + get().stopMemberPolling() + return + } + + get().stopMemberPolling() + polledMemberSessionId = sessionId + memberPollTimer = setInterval(() => { + const currentTabId = useTabStore.getState().activeTabId + if (currentTabId !== sessionId) { + get().stopMemberPolling() + return } - } + void get().refreshMemberSession(sessionId) + }, MEMBER_POLL_INTERVAL_MS) }, - clearTeam: () => set({ activeTeam: null, viewingAgentId: null, agentTranscript: [], memberColors: new Map() }), + stopMemberPolling: () => { + if (memberPollTimer) { + clearInterval(memberPollTimer) + memberPollTimer = null + } + polledMemberSessionId = null + }, + + clearTeam: () => { + get().stopMemberPolling() + set({ activeTeam: null, memberColors: new Map() }) + }, handleTeamCreated: (teamName: string) => { set((s) => ({ teams: [...s.teams, { name: teamName, memberCount: 0 }], })) - // Auto-fetch detail get().fetchTeamDetail(teamName) + setTimeout(() => get().fetchTeamDetail(teamName), 1500) + setTimeout(() => get().fetchTeamDetail(teamName), 4000) + setTimeout(() => get().fetchTeamDetail(teamName), 8000) }, handleTeamUpdate: (teamName: string, members: TeamMemberStatus[]) => { const team = get().activeTeam if (team && team.name === teamName) { + if (members.length === 0) return + + if (members.length > team.members.length) { + get().fetchTeamDetail(teamName) + } + const colors = get().memberColors - const updatedMembers: TeamMember[] = members.map((m, i) => ({ - ...m, - color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!, - })) + const existingMap = new Map(team.members.map((m) => [m.agentId, m])) + const incomingIds = new Set(members.map((m) => m.agentId)) + const kept = team.members.filter((m) => !incomingIds.has(m.agentId)) + const updatedMembers: TeamMember[] = [ + ...kept, + ...members.map((m, i) => { + const existing = existingMap.get(m.agentId) + return { + ...(existing ?? {}), + name: existing?.name, + agentId: m.agentId, + role: m.role, + status: normalizeMemberStatus(m.status), + currentTask: m.currentTask, + color: colors.get(m.agentId) ?? AGENT_COLORS[i % AGENT_COLORS.length]!, + sessionId: existing?.sessionId, + } + }), + ] set({ activeTeam: { ...team, members: updatedMembers } }) + + const currentTabId = useTabStore.getState().activeTabId + if (currentTabId) { + const viewedMember = get().getMemberBySessionId(currentTabId) + if (viewedMember) { + void get().refreshMemberSession(currentTabId) + get().startMemberPolling(currentTabId) + } + } } }, handleTeamDeleted: (teamName: string) => { + get().stopMemberPolling() set((s) => ({ teams: s.teams.filter((t) => t.name !== teamName), activeTeam: s.activeTeam?.name === teamName ? null : s.activeTeam, diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 2985df1a..dddebead 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -81,7 +81,7 @@ export type TaskSummaryItem = { } export type UIMessage = - | { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[] } + | { id: string; type: 'user_text'; content: string; timestamp: number; attachments?: UIAttachment[]; pending?: boolean } | { id: string; type: 'assistant_text'; content: string; timestamp: number; model?: string } | { id: string; type: 'thinking'; content: string; timestamp: number } | { id: string; type: 'tool_use'; toolName: string; toolUseId: string; input: unknown; timestamp: number; parentToolUseId?: string } diff --git a/desktop/src/types/team.ts b/desktop/src/types/team.ts index 54518bc3..50d1d201 100644 --- a/desktop/src/types/team.ts +++ b/desktop/src/types/team.ts @@ -8,25 +8,31 @@ export type TeamSummary = { export type TeamMember = { agentId: string + name?: string role: string status: 'running' | 'idle' | 'completed' | 'error' currentTask?: string color?: AgentColor + sessionId?: string } export type TeamDetail = { name: string + leadAgentId?: string + leadSessionId?: string members: TeamMember[] createdAt?: string } -export type TranscriptMessage = { - id: string - type: string - content: unknown - timestamp: string -} - export type AgentColor = 'red' | 'blue' | 'green' | 'yellow' | 'purple' | 'orange' | 'pink' | 'cyan' export const AGENT_COLORS: AgentColor[] = ['red', 'blue', 'green', 'yellow', 'purple', 'orange', 'pink', 'cyan'] + +/** Lifecycle message types that should be filtered from agent output display */ +export const AGENT_LIFECYCLE_TYPES = new Set([ + 'shutdown_approved', + 'shutdown_rejected', + 'shutdown_request', + 'teammate_terminated', + 'idle_notification', +]) diff --git a/src/server/__tests__/team-watcher.test.ts b/src/server/__tests__/team-watcher.test.ts index 1a90e38e..f107b95c 100644 --- a/src/server/__tests__/team-watcher.test.ts +++ b/src/server/__tests__/team-watcher.test.ts @@ -126,13 +126,13 @@ describe('TeamWatcher.extractMemberStatuses', () => { expect(statuses).toHaveLength(2) expect(statuses[0]).toEqual({ agentId: 'agent-lead', - role: 'lead', + role: 'Lead Agent', status: 'running', currentTask: undefined, }) expect(statuses[1]).toEqual({ agentId: 'agent-worker', - role: 'worker', + role: 'Worker Agent', status: 'idle', currentTask: undefined, }) @@ -153,12 +153,12 @@ describe('TeamWatcher.extractMemberStatuses', () => { expect(statuses[1]!.status).toBe('idle') }) - it('should use agentType as role', () => { + it('should prefer member name as role when present', () => { const config = makeTeamConfig() const statuses = watcher.extractMemberStatuses(config) - expect(statuses[0]!.role).toBe('lead') - expect(statuses[1]!.role).toBe('worker') + expect(statuses[0]!.role).toBe('Lead Agent') + expect(statuses[1]!.role).toBe('Worker Agent') }) it('should fall back to name when agentType is missing', () => { diff --git a/src/server/__tests__/teams.test.ts b/src/server/__tests__/teams.test.ts index f3d5918f..6402ae0a 100644 --- a/src/server/__tests__/teams.test.ts +++ b/src/server/__tests__/teams.test.ts @@ -59,6 +59,20 @@ async function writeTranscriptFile( return filePath } +async function writeSubagentTranscriptFile( + projectDir: string, + leadSessionId: string, + fileName: string, + entries: Record[], +): Promise { + const dir = path.join(tmpDir, 'projects', projectDir, leadSessionId, 'subagents') + await fs.mkdir(dir, { recursive: true }) + const filePath = path.join(dir, fileName) + const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n' + await fs.writeFile(filePath, content, 'utf-8') + return filePath +} + /** Create a standard team config for testing. */ function makeTeamConfig(overrides?: Record) { return { @@ -164,16 +178,61 @@ describe('TeamService', () => { // -------------------------------------------------------------------------- it('should return team detail with members', async () => { - await writeTeamConfig('detail-team', makeTeamConfig({ name: 'detail-team' })) + await writeTeamConfig( + 'detail-team', + makeTeamConfig({ + name: 'detail-team', + leadSessionId: 'lead-session-xyz', + }), + ) const detail = await service.getTeam('detail-team') expect(detail.name).toBe('detail-team') expect(detail.leadAgentId).toBe('agent-lead') + expect(detail.leadSessionId).toBe('lead-session-xyz') expect(detail.members).toHaveLength(2) expect(detail.members[0]!.agentId).toBe('agent-lead') expect(detail.members[1]!.agentId).toBe('agent-worker') }) + it('should discover missing in-process members from subagent transcripts', async () => { + await writeTeamConfig( + 'subagent-team', + makeTeamConfig({ + name: 'subagent-team', + leadSessionId: 'lead-session-subagents', + members: [ + { + agentId: 'agent-lead', + name: 'Lead Agent', + agentType: 'lead', + joinedAt: 1700000000000, + tmuxPaneId: '%0', + cwd: '/tmp/project', + sessionId: 'session-lead-001', + isActive: true, + }, + ], + }), + ) + + await writeSubagentTranscriptFile( + '-tmp-project', + 'lead-session-subagents', + 'agent-1.jsonl', + [ + { + agentName: 'security-reviewer', + agentId: 'security-reviewer@subagent-team', + timestamp: '2026-01-01T00:00:00.000Z', + }, + ], + ) + + const detail = await service.getTeam('subagent-team') + expect(detail.members.some((member) => member.name === 'security-reviewer')).toBe(true) + }) + it('should derive running status for active member', async () => { await writeTeamConfig('status-team', makeTeamConfig({ name: 'status-team' })) @@ -274,7 +333,7 @@ describe('TeamService', () => { expect( service.getMemberTranscript('member-team', 'nonexistent-agent'), - ).rejects.toThrow('Member not found') + ).rejects.toThrow('Team member not found') }) it('should skip meta entries in transcript', async () => { @@ -301,6 +360,61 @@ describe('TeamService', () => { expect(messages[0]!.id).toBe('msg-real') }) + // -------------------------------------------------------------------------- + // sendMemberMessage + // -------------------------------------------------------------------------- + + it('should write member messages into the teammate inbox', async () => { + await writeTeamConfig('mailbox-team', makeTeamConfig({ name: 'mailbox-team' })) + + await service.sendMemberMessage( + 'mailbox-team', + 'agent-worker', + 'Please review the latest diff', + ) + + const inboxPath = path.join( + tmpDir, + 'teams', + 'mailbox-team', + 'inboxes', + 'Worker-Agent.json', + ) + const rawInbox = await fs.readFile(inboxPath, 'utf-8') + const inbox = JSON.parse(rawInbox) as Array<{ + from: string + text: string + read: boolean + }> + + expect(inbox).toHaveLength(1) + expect(inbox[0]).toMatchObject({ + from: 'user', + text: 'Please review the latest diff', + read: false, + }) + }) + + it('should send messages to inbox-discovered members', async () => { + await writeTeamConfig('inbox-team', makeTeamConfig({ name: 'inbox-team' })) + const inboxDir = path.join(tmpDir, 'teams', 'inbox-team', 'inboxes') + await fs.mkdir(inboxDir, { recursive: true }) + await fs.writeFile(path.join(inboxDir, 'security-reviewer.json'), '[]', 'utf-8') + + await service.sendMemberMessage( + 'inbox-team', + 'security-reviewer@inbox-team', + 'Check the auth changes', + ) + + const rawInbox = await fs.readFile( + path.join(inboxDir, 'security-reviewer.json'), + 'utf-8', + ) + const inbox = JSON.parse(rawInbox) as Array<{ text: string }> + expect(inbox.at(-1)?.text).toBe('Check the auth changes') + }) + // -------------------------------------------------------------------------- // deleteTeam // -------------------------------------------------------------------------- @@ -395,7 +509,10 @@ describe('Teams API', () => { }) it('GET /api/teams/:name should return team detail', async () => { - await writeTeamConfig('detail', makeTeamConfig({ name: 'detail' })) + await writeTeamConfig( + 'detail', + makeTeamConfig({ name: 'detail', leadSessionId: 'leader-session-id' }), + ) const res = await fetch(`${baseUrl}/api/teams/detail`) expect(res.status).toBe(200) @@ -403,10 +520,12 @@ describe('Teams API', () => { const body = (await res.json()) as { name: string leadAgentId: string + leadSessionId?: string members: Array<{ agentId: string }> } expect(body.name).toBe('detail') expect(body.leadAgentId).toBe('agent-lead') + expect(body.leadSessionId).toBe('leader-session-id') expect(body.members).toHaveLength(2) }) @@ -448,6 +567,30 @@ describe('Teams API', () => { expect(res.status).toBe(404) }) + it('POST /api/teams/:name/members/:id/messages should enqueue a mailbox message', async () => { + await writeTeamConfig('send-team', makeTeamConfig({ name: 'send-team' })) + + const res = await fetch( + `${baseUrl}/api/teams/send-team/members/agent-worker/messages`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: 'Please continue with the failing test' }), + }, + ) + + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean } + expect(body.ok).toBe(true) + + const rawInbox = await fs.readFile( + path.join(tmpDir, 'teams', 'send-team', 'inboxes', 'Worker-Agent.json'), + 'utf-8', + ) + const inbox = JSON.parse(rawInbox) as Array<{ text: string }> + expect(inbox.at(-1)?.text).toBe('Please continue with the failing test') + }) + it('DELETE /api/teams/:name should delete team', async () => { const config = makeTeamConfig({ name: 'del-team' }) for (const member of (config as { members: Array> }).members) { diff --git a/src/server/api/teams.ts b/src/server/api/teams.ts index 1e2e5c8c..099e00dc 100644 --- a/src/server/api/teams.ts +++ b/src/server/api/teams.ts @@ -4,6 +4,7 @@ * GET /api/teams — 列出所有团队 * GET /api/teams/:name — 获取团队详情 * GET /api/teams/:name/members/:id/transcript — 获取成员 transcript + * POST /api/teams/:name/members/:id/messages — 给成员发送消息 * DELETE /api/teams/:name — 删除团队 */ @@ -38,6 +39,26 @@ export async function handleTeamsApi( return Response.json({ messages }) } + // ── POST /api/teams/:name/members/:id/messages ───────────────────────── + if ( + method === 'POST' && + teamName && + segments[3] === 'members' && + segments[4] && + segments[5] === 'messages' + ) { + const agentId = decodeURIComponent(segments[4]) + let body: { content?: string } + try { + body = (await req.json()) as { content?: string } + } catch { + throw ApiError.badRequest('Invalid JSON body') + } + + await teamService.sendMemberMessage(teamName, agentId, body.content ?? '') + return Response.json({ ok: true }) + } + // ── GET /api/teams/:name ────────────────────────────────────────────── if (method === 'GET' && teamName) { const team = await teamService.getTeam(teamName) diff --git a/src/server/services/teamService.ts b/src/server/services/teamService.ts index 86d6dffd..0a3cf2a1 100644 --- a/src/server/services/teamService.ts +++ b/src/server/services/teamService.ts @@ -2,14 +2,17 @@ * TeamService — 读取 CLI 生成的 Agent Teams 配置 * * Team 配置存储在 ~/.claude/teams/{name}/config.json - * 成员 transcript 存储为 JSONL 文件,通过 sessionId 在 ~/.claude/projects/ 下定位。 - * 服务端只读取,不负责创建团队(由 CLI 的 TeamCreate 工具完成)。 + * 成员 transcript 存储为 JSONL 文件: + * - 有 sessionId 的成员: ~/.claude/projects/{project}/{sessionId}.jsonl + * - in-process 成员 (无 sessionId): ~/.claude/projects/{project}/{leadSessionId}/subagents/agent-*.jsonl + * 成员发现: config.json + inboxes/ 目录 (解决并发写入丢失成员的问题) */ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' +import { writeToMailbox } from '../../utils/teammateMailbox.js' // ─── Types ───────────────────────────────────────────────────────────────── @@ -19,6 +22,7 @@ export type TeamMember = { agentType?: string model?: string color?: string + backendType?: string status: 'running' | 'completed' | 'idle' | 'failed' joinedAt: number cwd: string @@ -35,6 +39,7 @@ export type TeamSummary = { export type TeamDetail = TeamSummary & { leadAgentId: string + leadSessionId?: string members: TeamMember[] } @@ -43,6 +48,8 @@ export type TranscriptMessage = { type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' content: unknown timestamp: string + model?: string + parentToolUseId?: string } /** Raw config.json structure written by CLI */ @@ -64,6 +71,7 @@ type TeamFileRaw = { cwd: string worktreePath?: string sessionId?: string + backendType?: string isActive?: boolean mode?: string }> @@ -103,7 +111,14 @@ export class TeamService { try { const config = await this.loadTeamConfig(entry.name) - teams.push(this.toSummary(config)) + // Include inbox-discovered members in the count + const inboxNames = await this.discoverInboxMembers(entry.name) + const configNames = new Set(config.members.map((m) => m.name)) + const extraCount = inboxNames.filter((n) => !configNames.has(n)).length + const summary = this.toSummary(config) + summary.memberCount += extraCount + summary.activeMemberCount += extraCount // assume running if newly discovered + teams.push(summary) } catch { // Skip malformed team directories } @@ -123,15 +138,58 @@ export class TeamService { agentType: m.agentType, model: m.model, color: m.color, + backendType: m.backendType, status: this.deriveStatus(m.isActive), joinedAt: m.joinedAt, cwd: m.cwd, sessionId: m.sessionId, })) + // Discover members from inboxes/ that aren't in config.json (race condition fix) + const inboxNames = await this.discoverInboxMembers(name) + const configNames = new Set(config.members.map((m) => m.name)) + + for (const inboxName of inboxNames) { + if (!configNames.has(inboxName)) { + members.push({ + agentId: `${inboxName}@${name}`, + name: inboxName, + agentType: 'general-purpose', + status: 'running', // assume running since we can see their inbox + joinedAt: config.createdAt, + cwd: config.members[0]?.cwd || '', + }) + } + } + + if (config.leadSessionId) { + const subagentNames = await this.discoverSubagentMembers( + config.leadSessionId, + ) + for (const subagentName of subagentNames) { + if ( + !configNames.has(subagentName) && + !members.some((member) => member.name === subagentName) + ) { + members.push({ + agentId: `${subagentName}@${name}`, + name: subagentName, + status: 'running', + joinedAt: config.createdAt, + cwd: config.members[0]?.cwd || '', + }) + } + } + } + return { ...this.toSummary(config), leadAgentId: config.leadAgentId, + leadSessionId: config.leadSessionId, + memberCount: members.length, + activeMemberCount: members.filter( + (m) => m.status === 'running', + ).length, members, } } @@ -143,24 +201,68 @@ export class TeamService { agentId: string, ): Promise { const config = await this.loadTeamConfig(teamName) - - const member = config.members.find((m) => m.agentId === agentId) - if (!member) { + const memberName = await this.resolveMemberName(config, teamName, agentId) + if (!memberName) { throw ApiError.notFound( - `Member not found: ${agentId} in team ${teamName}`, + `Team member not found: ${agentId} in team ${teamName}`, ) } - if (!member.sessionId) { - return [] + // Try config.json member with sessionId first + const member = config.members.find((m) => m.agentId === agentId) + if (member?.sessionId) { + const jsonlPath = await this.findTranscriptFile(member.sessionId) + if (jsonlPath) { + return this.parseTranscriptFile(jsonlPath) + } } - const jsonlPath = await this.findTranscriptFile(member.sessionId) - if (!jsonlPath) { - return [] + // Fallback: search subagents directory for this member's transcript + if (config.leadSessionId) { + const subagentPath = await this.findSubagentTranscript( + config.leadSessionId, + memberName, + ) + if (subagentPath) { + return this.parseTranscriptFile(subagentPath) + } } - return this.parseTranscriptFile(jsonlPath) + return [] + } + + async sendMemberMessage( + teamName: string, + agentId: string, + content: string, + ): Promise { + const text = content.trim() + if (!text) { + throw ApiError.badRequest('content (string) is required in request body') + } + + const config = await this.loadTeamConfig(teamName) + const recipientName = await this.resolveMemberName( + config, + teamName, + agentId, + ) + + if (!recipientName) { + throw ApiError.notFound( + `Team member not found: ${agentId} in team ${teamName}`, + ) + } + + await writeToMailbox( + recipientName, + { + from: 'user', + text, + timestamp: new Date().toISOString(), + }, + teamName, + ) } // ── Delete team ───────────────────────────────────────────────────────── @@ -194,6 +296,25 @@ export class TeamService { } } + /** + * Discover member names from the inboxes/ directory. + * Each file `{name}.json` in inboxes/ represents a team member. + * Excludes the team-lead inbox since the leader is already in config. + */ + private async discoverInboxMembers(teamName: string): Promise { + const inboxDir = path.join(this.getTeamsDir(), teamName, 'inboxes') + + try { + const files = await fs.readdir(inboxDir) + return files + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')) + .filter((name) => name !== 'team-lead') + } catch { + return [] + } + } + private toSummary(config: TeamFileRaw): TeamSummary { const activeMemberCount = config.members.filter( (m) => m.isActive === undefined || m.isActive === true, @@ -216,6 +337,79 @@ export class TeamService { return 'running' } + private async resolveMemberName( + config: TeamFileRaw, + teamName: string, + agentId: string, + ): Promise { + const configMember = config.members.find((m) => m.agentId === agentId) + if (configMember?.name) { + return configMember.name + } + + const parsedName = agentId.includes('@') ? agentId.split('@')[0]! : agentId + const inboxNames = await this.discoverInboxMembers(teamName) + if (inboxNames.includes(parsedName)) { + return parsedName + } + + if (config.leadSessionId) { + const subagentNames = await this.discoverSubagentMembers( + config.leadSessionId, + ) + if (subagentNames.includes(parsedName)) { + return parsedName + } + } + + return null + } + + private async discoverSubagentMembers(leadSessionId: string): Promise { + const projectsDir = this.getProjectsDir() + + try { + await fs.access(projectsDir) + } catch { + return [] + } + + const discovered = new Set() + const projectEntries = await fs.readdir(projectsDir, { + withFileTypes: true, + }) + + for (const projEntry of projectEntries) { + if (!projEntry.isDirectory()) continue + + const subagentsDir = path.join( + projectsDir, + projEntry.name, + leadSessionId, + 'subagents', + ) + + let files: string[] + try { + files = await fs.readdir(subagentsDir) + } catch { + continue + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const discoveredName = await this.extractSubagentName( + path.join(subagentsDir, file), + ) + if (discoveredName && discoveredName !== 'team-lead') { + discovered.add(discoveredName) + } + } + } + + return [...discovered] + } + /** Search ~/.claude/projects/ for a JSONL file matching the sessionId. */ private async findTranscriptFile( sessionId: string, @@ -247,6 +441,122 @@ export class TeamService { return null } + /** + * Search subagents directory for a specific member's transcript. + * Path: ~/.claude/projects/{project}/{leadSessionId}/subagents/agent-*.jsonl + * + * Matches by reading the first user message and checking for the member name + * in the `` content (e.g., "你是 **security-reviewer**"). + */ + private async findSubagentTranscript( + leadSessionId: string, + memberName: string, + ): Promise { + const projectsDir = this.getProjectsDir() + + try { + await fs.access(projectsDir) + } catch { + return null + } + + const projectEntries = await fs.readdir(projectsDir, { + withFileTypes: true, + }) + + for (const projEntry of projectEntries) { + if (!projEntry.isDirectory()) continue + + const subagentsDir = path.join( + projectsDir, + projEntry.name, + leadSessionId, + 'subagents', + ) + + let files: string[] + try { + files = await fs.readdir(subagentsDir) + } catch { + continue + } + + // Check each subagent file — find the one matching this member + // Use the most recent file if multiple match (handles retries) + let bestMatch: { path: string; mtime: number } | null = null + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + + const filePath = path.join(subagentsDir, file) + + try { + const head = await this.readTranscriptHead(filePath) + if ( + head.includes(`"${memberName}"`) || + head.includes(`**${memberName}**`) || + head.includes(`name":"${memberName}`) || + (await this.extractSubagentName(filePath)) === memberName + ) { + const stat = await fs.stat(filePath) + if (!bestMatch || stat.mtimeMs > bestMatch.mtime) { + bestMatch = { path: filePath, mtime: stat.mtimeMs } + } + } + } catch { + // Skip unreadable files + } + } + + if (bestMatch) { + return bestMatch.path + } + } + + return null + } + + private async readTranscriptHead(filePath: string): Promise { + const fd = await fs.open(filePath, 'r') + try { + const buf = Buffer.alloc(8192) + const { bytesRead } = await fd.read(buf, 0, 8192, 0) + return buf.toString('utf-8', 0, bytesRead) + } finally { + await fd.close() + } + } + + private async extractSubagentName(filePath: string): Promise { + try { + const head = await this.readTranscriptHead(filePath) + const lines = head.split('\n').filter((line) => line.trim().length > 0) + + for (const line of lines) { + try { + const entry = JSON.parse(line) as Record + if (typeof entry.agentName === 'string' && entry.agentName.trim()) { + return entry.agentName + } + if (typeof entry.agentId === 'string' && entry.agentId.includes('@')) { + return entry.agentId.split('@')[0] ?? null + } + } catch { + // Ignore partial or non-JSON lines in the preview window. + } + } + + const nameMatch = + head.match(/"agentName"\s*:\s*"([^"]+)"/) || + head.match(/"name"\s*:\s*"([^"]+)"/) || + head.match(/\*\*([a-zA-Z0-9_-]+)\*\*/) + + return nameMatch?.[1] ?? null + } catch { + return null + } + } + /** Parse a JSONL transcript file into messages. */ private async parseTranscriptFile( filePath: string, @@ -281,6 +591,8 @@ export class TeamService { content: entry.message ?? entry.content ?? null, timestamp: (entry.timestamp as string) || new Date().toISOString(), + ...(typeof entry.parentToolUseId === 'string' ? { parentToolUseId: entry.parentToolUseId } : {}), + ...(typeof entry.model === 'string' ? { model: entry.model } : {}), } messages.push(message) diff --git a/src/server/services/teamWatcher.ts b/src/server/services/teamWatcher.ts index bf0f3ea0..055a5e04 100644 --- a/src/server/services/teamWatcher.ts +++ b/src/server/services/teamWatcher.ts @@ -100,10 +100,18 @@ export class TeamWatcher { try { const config = JSON.parse(content) const members = this.extractMemberStatuses(config) - this.broadcast({ type: 'team_update', teamName, members }) + // Merge inbox-discovered members that are missing from config + const inboxMembers = this.discoverInboxMembers(teamsDir, teamName, config) + const subagentMembers = this.discoverSubagentMembers(teamsDir, config) + const allMembers = [...members, ...inboxMembers, ...subagentMembers] + this.broadcast({ type: 'team_update', teamName, members: allMembers }) } catch { - // JSON parse failed -- broadcast with empty members - this.broadcast({ type: 'team_update', teamName, members: [] }) + // JSON parse failed (likely truncated write) — try to recover partial members + const recovered = this.recoverPartialMembers(content) + if (recovered.length > 0) { + this.broadcast({ type: 'team_update', teamName, members: recovered }) + } + // If nothing recoverable, skip broadcast entirely — don't send empty members } } // else: content unchanged, nothing to do @@ -136,7 +144,7 @@ export class TeamWatcher { const status = this.deriveStatus(m.isActive as boolean | undefined) return { agentId: (m.agentId as string) || '', - role: (m.agentType as string) || (m.name as string) || 'member', + role: (m.name as string) || (m.agentType as string) || 'member', status, currentTask: (m.currentTask as string) || undefined, } @@ -149,6 +157,171 @@ export class TeamWatcher { return 'running' } + /** + * Discover members from inboxes/ that aren't in config.json. + * Fixes the race condition where concurrent writes to config.json lose members. + */ + private discoverInboxMembers( + teamsDir: string, + teamName: string, + config: Record, + ): TeamMemberStatus[] { + const inboxDir = path.join(teamsDir, teamName, 'inboxes') + const configMembers = Array.isArray(config.members) ? config.members : [] + const configNames = new Set( + configMembers.map((m: Record) => m.name as string), + ) + + try { + const files = fs.readdirSync(inboxDir) + const extra: TeamMemberStatus[] = [] + + for (const file of files) { + if (!file.endsWith('.json')) continue + const name = file.replace(/\.json$/, '') + if (name === 'team-lead' || configNames.has(name)) continue + + extra.push({ + agentId: `${name}@${teamName}`, + role: name, + status: 'running', // assume running — they have an inbox + }) + } + + return extra + } catch { + return [] + } + } + + private discoverSubagentMembers( + teamsDir: string, + config: Record, + ): TeamMemberStatus[] { + const leadSessionId = + typeof config.leadSessionId === 'string' ? config.leadSessionId : null + const teamName = typeof config.name === 'string' ? config.name : 'team' + if (!leadSessionId) return [] + + const configMembers = Array.isArray(config.members) ? config.members : [] + const configNames = new Set( + configMembers.map((m: Record) => m.name as string), + ) + const projectsDir = path.join(path.dirname(teamsDir), 'projects') + + try { + const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true }) + const extra = new Map() + + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue + const subagentsDir = path.join( + projectsDir, + entry.name, + leadSessionId, + 'subagents', + ) + + let files: string[] + try { + files = fs.readdirSync(subagentsDir) + } catch { + continue + } + + for (const file of files) { + if (!file.endsWith('.jsonl')) continue + const inferredName = this.extractSubagentName( + path.join(subagentsDir, file), + ) + if ( + inferredName && + inferredName !== 'team-lead' && + !configNames.has(inferredName) && + !extra.has(inferredName) + ) { + extra.set(inferredName, { + agentId: `${inferredName}@${teamName}`, + role: inferredName, + status: 'running', + }) + } + } + } + + return [...extra.values()] + } catch { + return [] + } + } + + /** + * Attempt to recover member data from truncated/corrupted JSON. + * Extracts agentId values via regex and constructs minimal member statuses. + */ + private recoverPartialMembers(rawContent: string): TeamMemberStatus[] { + const members: TeamMemberStatus[] = [] + // Match complete member-like objects: find "agentId":"..." patterns + const agentIdRegex = /"agentId"\s*:\s*"([^"]+)"/g + const nameRegex = /"(?:agentType|name)"\s*:\s*"([^"]+)"/g + const isActiveRegex = /"isActive"\s*:\s*(true|false)/g + + const agentIds: string[] = [] + const names: string[] = [] + const activeStates: (boolean | undefined)[] = [] + + let match: RegExpExecArray | null + while ((match = agentIdRegex.exec(rawContent)) !== null) { + agentIds.push(match[1]!) + } + while ((match = nameRegex.exec(rawContent)) !== null) { + names.push(match[1]!) + } + while ((match = isActiveRegex.exec(rawContent)) !== null) { + activeStates.push(match[1] === 'true') + } + + for (let i = 0; i < agentIds.length; i++) { + members.push({ + agentId: agentIds[i]!, + role: names[i] || 'member', + status: this.deriveStatus(activeStates[i]), + }) + } + + return members + } + + private extractSubagentName(filePath: string): string | null { + try { + const head = fs.readFileSync(filePath, 'utf-8').slice(0, 8192) + const lines = head.split('\n').filter((line) => line.trim().length > 0) + + for (const line of lines) { + try { + const entry = JSON.parse(line) as Record + if (typeof entry.agentName === 'string' && entry.agentName.trim()) { + return entry.agentName + } + if (typeof entry.agentId === 'string' && entry.agentId.includes('@')) { + return entry.agentId.split('@')[0] ?? null + } + } catch { + // Ignore malformed preview lines. + } + } + + const match = + head.match(/"agentName"\s*:\s*"([^"]+)"/) || + head.match(/"name"\s*:\s*"([^"]+)"/) || + head.match(/\*\*([a-zA-Z0-9_-]+)\*\*/) + + return match?.[1] ?? null + } catch { + return null + } + } + // ── Broadcasting ─────────────────────────────────────────────────────── private broadcast(message: ServerMessage): void { diff --git a/src/tools/shared/spawnMultiAgent.ts b/src/tools/shared/spawnMultiAgent.ts index dc7c8dd0..ac68a3cf 100644 --- a/src/tools/shared/spawnMultiAgent.ts +++ b/src/tools/shared/spawnMultiAgent.ts @@ -51,10 +51,10 @@ import { } from '../../utils/swarm/spawnInProcess.js' import { buildInheritedEnvVars } from '../../utils/swarm/spawnUtils.js' import { + mutateTeamFileAsync, readTeamFileAsync, sanitizeAgentName, sanitizeName, - writeTeamFileAsync, } from '../../utils/swarm/teamHelpers.js' import { assignTeammateColor, @@ -485,28 +485,24 @@ async function handleSpawnSplitPane( toolUseId: context.toolUseId, }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } - teamFile.members.push({ - agentId: teammateId, - name: sanitizedName, - agentType: agent_type, - model, - prompt, - color: teammateColor, - planModeRequired: plan_mode_required, - joinedAt: Date.now(), - tmuxPaneId: paneId, - cwd: workingDir, - subscriptions: [], - backendType: detectionResult.backend.type, + // Register agent in the team file using atomic read-modify-write to avoid + // concurrent spawn clobbering other members. + await mutateTeamFileAsync(teamName, (teamFile) => { + teamFile.members.push({ + agentId: teammateId, + name: sanitizedName, + agentType: agent_type, + model, + prompt, + color: teammateColor, + planModeRequired: plan_mode_required, + joinedAt: Date.now(), + tmuxPaneId: paneId, + cwd: workingDir, + subscriptions: [], + backendType: detectionResult.backend.type, + }) }) - await writeTeamFileAsync(teamName, teamFile) // Send initial instructions to teammate via mailbox // The teammate's inbox poller will pick this up and submit it as their first turn @@ -699,28 +695,22 @@ async function handleSpawnSeparateWindow( toolUseId: context.toolUseId, }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } - teamFile.members.push({ - agentId: teammateId, - name: sanitizedName, - agentType: agent_type, - model, - prompt, - color: teammateColor, - planModeRequired: plan_mode_required, - joinedAt: Date.now(), - tmuxPaneId: paneId, - cwd: workingDir, - subscriptions: [], - backendType: 'tmux', // This handler always uses tmux directly + await mutateTeamFileAsync(teamName, (teamFile) => { + teamFile.members.push({ + agentId: teammateId, + name: sanitizedName, + agentType: agent_type, + model, + prompt, + color: teammateColor, + planModeRequired: plan_mode_required, + joinedAt: Date.now(), + tmuxPaneId: paneId, + cwd: workingDir, + subscriptions: [], + backendType: 'tmux', // This handler always uses tmux directly + }) }) - await writeTeamFileAsync(teamName, teamFile) // Send initial instructions to teammate via mailbox // The teammate's inbox poller will pick this up and submit it as their first turn @@ -985,28 +975,22 @@ async function handleSpawnInProcess( } }) - // Register agent in the team file - const teamFile = await readTeamFileAsync(teamName) - if (!teamFile) { - throw new Error( - `Team "${teamName}" does not exist. Call spawnTeam first to create the team.`, - ) - } - teamFile.members.push({ - agentId: teammateId, - name: sanitizedName, - agentType: agent_type, - model, - prompt, - color: teammateColor, - planModeRequired: plan_mode_required, - joinedAt: Date.now(), - tmuxPaneId: 'in-process', - cwd: getCwd(), - subscriptions: [], - backendType: 'in-process', + await mutateTeamFileAsync(teamName, (teamFile) => { + teamFile.members.push({ + agentId: teammateId, + name: sanitizedName, + agentType: agent_type, + model, + prompt, + color: teammateColor, + planModeRequired: plan_mode_required, + joinedAt: Date.now(), + tmuxPaneId: 'in-process', + cwd: getCwd(), + subscriptions: [], + backendType: 'in-process', + }) }) - await writeTeamFileAsync(teamName, teamFile) // Note: Do NOT send the prompt via mailbox for in-process teammates. // In-process teammates receive the prompt directly via startInProcessTeammate(). diff --git a/src/utils/swarm/teamHelpers.ts b/src/utils/swarm/teamHelpers.ts index 66508fd7..f854e100 100644 --- a/src/utils/swarm/teamHelpers.ts +++ b/src/utils/swarm/teamHelpers.ts @@ -9,6 +9,7 @@ import { errorMessage, getErrnoCode } from '../errors.js' import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' import { gitExe } from '../git.js' import { lazySchema } from '../lazySchema.js' +import * as lockfile from '../lockfile.js' import type { PermissionMode } from '../permissions/PermissionMode.js' import { jsonParse, jsonStringify } from '../slowOperations.js' import { getTasksDir, notifyTasksUpdated } from '../tasks.js' @@ -181,6 +182,50 @@ export async function writeTeamFileAsync( await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2)) } +/** + * Atomically read-modify-write a team file under a file lock. + * Use this for concurrent member updates such as teammate spawning. + */ +export async function mutateTeamFileAsync( + teamName: string, + mutator: (teamFile: TeamFile) => TeamFile | void, +): Promise { + const teamDir = getTeamDir(teamName) + const teamFilePath = getTeamFilePath(teamName) + const lockFilePath = `${teamFilePath}.lock` + + await mkdir(teamDir, { recursive: true }) + try { + await writeFile(teamFilePath, '{}', { encoding: 'utf-8', flag: 'wx' }) + } catch (e) { + if (getErrnoCode(e) !== 'EEXIST') { + throw e + } + } + + const release = await lockfile.lock(teamFilePath, { + lockfilePath: lockFilePath, + retries: { + retries: 10, + minTimeout: 5, + maxTimeout: 100, + }, + }) + + try { + const current = await readTeamFileAsync(teamName) + if (!current || !Array.isArray(current.members)) { + throw new Error(`Team "${teamName}" does not exist`) + } + + const next = mutator(current) ?? current + await writeFile(teamFilePath, jsonStringify(next, null, 2)) + return next + } finally { + await release() + } +} + /** * Removes a teammate from the team file by agent ID or name. * Used by the leader when processing shutdown approvals.