From 23b31b397ad032e5bf72af6a046d3d76fe104bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 14 Apr 2026 17:27:07 +0800 Subject: [PATCH] Prevent local team sessions from dropping members and stalling adapters This bundles the pending desktop/server team-session fixes with the local adapter recovery changes already in the worktree. The team path now keeps teammate membership stable under concurrent spawns, surfaces real teammate identities in the desktop UI, and allows direct interaction with member transcripts. The adapter changes recover automatically when stale thinking signatures invalidate an existing session. Constraint: Team config writes can happen concurrently while multiple reviewers spawn in parallel Constraint: Desktop member views must follow mailbox/transcript semantics rather than hijacking teammate runtime sessions Rejected: Keep relying on config.json alone for member discovery | in-process teammates can be lost after concurrent writes Rejected: Open teammate sessionIds as normal desktop sessions | would attach a second CLI instead of the running teammate Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve locked team-file mutation for any future teammate registration path and keep teammate labels sourced from member names before agent types Tested: bun test src/server/__tests__/teams.test.ts src/server/__tests__/team-watcher.test.ts Tested: cd desktop && bun run test --run src/stores/chatStore.test.ts src/pages/ActiveSession.test.tsx Tested: cd desktop && bun run lint Tested: cd desktop && bun run build Not-tested: Manual end-to-end validation against a live Agent Teams run in the desktop app --- .gitignore | 1 + adapters/feishu/index.ts | 29 +- adapters/telegram/index.ts | 28 +- desktop/src/__tests__/pages.test.tsx | 15 +- desktop/src/api/teams.ts | 21 +- desktop/src/components/chat/ChatInput.tsx | 171 +++++---- desktop/src/components/chat/MessageList.tsx | 2 +- desktop/src/components/chat/ToolCallGroup.tsx | 38 +- .../src/components/layout/ContentRouter.tsx | 9 +- desktop/src/components/teams/BackToLeader.tsx | 16 - desktop/src/components/teams/MemberTag.tsx | 46 --- .../src/components/teams/TeamStatusBar.tsx | 152 +++++++- .../src/components/teams/TranscriptView.tsx | 38 -- desktop/src/i18n/locales/en.ts | 7 + desktop/src/i18n/locales/zh.ts | 7 + desktop/src/pages/ActiveSession.test.tsx | 74 ++++ desktop/src/pages/ActiveSession.tsx | 169 ++++++--- desktop/src/pages/AgentTranscript.tsx | 189 ---------- desktop/src/stores/chatStore.test.ts | 94 ++++- desktop/src/stores/chatStore.ts | 189 +++++++++- desktop/src/stores/teamStore.ts | 294 +++++++++++++-- desktop/src/types/chat.ts | 2 +- desktop/src/types/team.ts | 20 +- src/server/__tests__/team-watcher.test.ts | 10 +- src/server/__tests__/teams.test.ts | 149 +++++++- src/server/api/teams.ts | 21 ++ src/server/services/teamService.ts | 338 +++++++++++++++++- src/server/services/teamWatcher.ts | 181 +++++++++- src/tools/shared/spawnMultiAgent.ts | 112 +++--- src/utils/swarm/teamHelpers.ts | 45 +++ 30 files changed, 1856 insertions(+), 611 deletions(-) delete mode 100644 desktop/src/components/teams/BackToLeader.tsx delete mode 100644 desktop/src/components/teams/MemberTag.tsx delete mode 100644 desktop/src/components/teams/TranscriptView.tsx delete mode 100644 desktop/src/pages/AgentTranscript.tsx 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.