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
This commit is contained in:
程序员阿江(Relakkes) 2026-04-14 17:27:07 +08:00
parent 9eb3be4b11
commit 23b31b397a
30 changed files with 1856 additions and 611 deletions

1
.gitignore vendored
View File

@ -40,6 +40,7 @@ docs/superpowers/
# Debug / temp screenshots (root dir only)
/*.png
bug_attachments/
# Private keys & credentials
*.pem

View File

@ -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}`)

View File

@ -237,6 +237,11 @@ async function ensureSession(chatId: string): Promise<boolean> {
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
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':

View File

@ -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(<AgentTranscript />)
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(<ScheduledTasks />)
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(<Page />)
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(<AgentTranscript />)
expect(container.innerHTML).toContain('Navigation.tsx')
})
})

View File

@ -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<TeamsResponse>('/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)}`)
},

View File

@ -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<GitInfo | null>(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<HTMLTextAreaElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>
@ -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 && (
<FileSearchMenu
ref={fileSearchRef}
cwd={gitInfo?.workDir || activeSession?.workDir || ''}
@ -400,7 +425,7 @@ export function ChatInput() {
/>
)}
{slashMenuOpen && filteredCommands.length > 0 && (
{!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
@ -457,7 +482,9 @@ export function ChatInput() {
placeholder={
isWorkspaceMissing
? t('chat.placeholderMissing')
: t('chat.placeholder')
: isMemberSession
? t('teams.memberPlaceholder')
: t('chat.placeholder')
}
disabled={isWorkspaceMissing}
rows={1}
@ -466,55 +493,59 @@ export function ChatInput() {
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-between border-t border-[#dac1ba]/10 px-3 py-3">
<div className="flex items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label="Open composer tools"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{!isMemberSession && (
<>
<div ref={plusMenuRef} className="relative">
<button
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label="Open composer tools"
className="rounded-[var(--radius-md)] p-1.5 text-[var(--color-text-secondary)] transition-colors hover:bg-[#F4F4F0]"
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
</button>
{plusMenuOpen && (
<div className="absolute bottom-full left-0 z-50 mb-2 w-[240px] rounded-xl border border-[#dac1ba]/20 bg-white py-1 shadow-[0_18px_48px_rgba(27,28,26,0.12)]">
<button
onClick={() => {
fileInputRef.current?.click()
setPlusMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.addFiles')}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[#F8F7F4]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{t('chat.slashCommands')}</span>
</button>
</div>
)}
</div>
)}
</div>
<PermissionModeSelector />
<PermissionModeSelector />
</>
)}
</div>
<div className="flex items-center gap-2">
<ModelSelector />
{!isMemberSession && <ModelSelector />}
<button
onClick={isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isActive && !canSubmit}
title={isActive ? t('chat.stopTitle') : undefined}
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
title={!isMemberSession && isActive ? t('chat.stopTitle') : undefined}
className={`flex w-[112px] items-center justify-center gap-1 rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-all hover:opacity-90 disabled:opacity-30 ${
isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
!isMemberSession && isActive ? 'bg-[var(--color-error)]' : 'bg-[var(--color-brand)]'
}`}
>
<span className="material-symbols-outlined text-[14px]">
{isActive ? 'stop' : 'arrow_forward'}
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
{isActive ? t('common.stop') : t('common.run')}
{!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
</button>
</div>
</div>
@ -522,33 +553,33 @@ export function ChatInput() {
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileSelect} />
<div className="mt-3 px-1">
{hasMessages ? (
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
) : (
<DirectoryPicker
value={gitInfo?.workDir || activeSession?.workDir || ''}
onChange={async (newWorkDir) => {
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(() => {})
}}
/>
)}
</div>
{!isMemberSession && (
<div className="mt-3 px-1">
{hasMessages ? (
<ProjectContextChip
workDir={gitInfo?.workDir || activeSession?.workDir}
repoName={gitInfo?.repoName || null}
branch={gitInfo?.branch || null}
/>
) : (
<DirectoryPicker
value={gitInfo?.workDir || activeSession?.workDir || ''}
onChange={async (newWorkDir) => {
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(() => {})
}}
/>
)}
</div>
)}
</div>
</div>
)

View File

@ -160,7 +160,7 @@ export function MessageList() {
)
}
const MessageBlock = memo(function MessageBlock({
export const MessageBlock = memo(function MessageBlock({
message,
activeThinkingId,
agentTaskNotifications,

View File

@ -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<UIMessage, { type: 'tool_use' }>
type ToolResult = Extract<UIMessage, { type: 'tool_result' }>
@ -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({
</span>
)}
</div>
{!expanded && recentToolCalls.length > 0 && (
{!expanded && outputSummary && (
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
{outputSummary}
</div>
)}
{!expanded && !outputSummary && recentToolCalls.length > 0 && (
<div className="mt-1 space-y-1">
{recentToolCalls.map((recentToolCall) => (
<div
@ -318,16 +324,11 @@ function AgentCallCard({
))}
</div>
)}
{!expanded && !recentToolCalls.length && errorText && (
{!expanded && !outputSummary && !recentToolCalls.length && errorText && (
<div className="mt-1 truncate text-[11px] text-[var(--color-error)]">
{errorText}
</div>
)}
{!expanded && !recentToolCalls.length && !errorText && outputSummary && (
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
{outputSummary}
</div>
)}
</div>
{outputSummary && (
<button
@ -565,6 +566,27 @@ function isAgentLaunchResult(content: unknown): boolean {
)
}
/**
* Check if agent result content is a lifecycle notification (shutdown, terminated, etc.)
* rather than actual agent output. These should not be shown to the user as results.
*/
function isAgentLifecycleResult(content: unknown): boolean {
const text = extractTextContent(content).trim()
if (!text) return false
// Detect JSON lifecycle messages: shutdown_approved, shutdown_rejected, teammate_terminated
if (text.startsWith('{') && text.endsWith('}')) {
try {
const parsed = JSON.parse(text) as Record<string, unknown>
if (typeof parsed.type === 'string' && AGENT_LIFECYCLE_TYPES.has(parsed.type)) {
return true
}
} catch {
// Not valid JSON, not a lifecycle message
}
}
return false
}
function extractTextContent(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {

View File

@ -1,15 +1,12 @@
import { useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { EmptySession } from '../../pages/EmptySession'
import { ActiveSession } from '../../pages/ActiveSession'
import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { Settings } from '../../pages/Settings'
import { AgentTranscript } from '../../pages/AgentTranscript'
export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId)
const activeTabType = useTabStore((s) => s.tabs.find((t) => t.sessionId === s.activeTabId)?.type)
const viewingAgentId = useTeamStore((s) => s.viewingAgentId)
// No tabs open — show empty session
if (!activeTabId || !activeTabType) {
@ -25,10 +22,6 @@ export function ContentRouter() {
return <ScheduledTasks />
}
// Session tab — show agent transcript or active session
if (viewingAgentId) {
return <AgentTranscript />
}
// Session tab — ActiveSession handles both regular and member sessions
return <ActiveSession />
}

View File

@ -1,16 +0,0 @@
import { useTeamStore } from '../../stores/teamStore'
import { useTranslation } from '../../i18n'
export function BackToLeader() {
const setViewingAgent = useTeamStore((s) => s.setViewingAgent)
const t = useTranslation()
return (
<button
onClick={() => setViewingAgent(null)}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[var(--color-text-accent)] hover:underline transition-colors"
>
{t('teams.backToLeader')}
</button>
)
}

View File

@ -1,46 +0,0 @@
import { useTeamStore } from '../../stores/teamStore'
import type { TeamMember, AgentColor } from '../../types/team'
const COLOR_MAP: Record<AgentColor, string> = {
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<string, { dotClass: string; color: string }> = {
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 (
<button
onClick={() => setViewingAgent(isViewing ? null : member.agentId)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-[var(--radius-md)] text-xs font-medium transition-colors"
style={{
backgroundColor: isViewing ? `color-mix(in srgb, ${agentColor} 15%, transparent)` : 'var(--color-surface-selected)',
border: isViewing ? `2px solid ${agentColor}` : '2px solid transparent',
}}
>
{/* Status dot */}
<span
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dotClass}`}
style={{ backgroundColor: statusInfo.color }}
/>
<span className="text-[var(--color-text-primary)]">{member.role || member.agentId}</span>
</button>
)
}

View File

@ -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 (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-info)]">
{/* Team header */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{t('teams.team')} {activeTeam.name}
</span>
<span className="text-xs text-[var(--color-text-tertiary)]">
({activeTeam.members.length} {t('teams.members')})
</span>
</div>
// 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 */}
<div className="px-4 pb-2 flex flex-wrap gap-2">
{activeTeam.members.map((member) => (
<MemberTag key={member.agentId} member={member} />
))}
return (
<div className="shrink-0 px-8">
<div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
{/* Header */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--color-surface-container-low)] transition-colors bg-[var(--color-surface-container)]"
>
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-brand)]/10">
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">groups</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{t('teams.team')} {activeTeam.name}
</span>
{/* Progress bar */}
<div className="flex-1 h-1.5 rounded-full bg-[var(--color-border)] overflow-hidden max-w-[200px]">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progressPercent}%`,
backgroundColor: allDone ? 'var(--color-success)' : 'var(--color-brand)',
}}
/>
</div>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
{completedCount}/{totalCount}
</span>
{runningCount > 0 && (
<span className="flex items-center gap-1 text-[10px] text-[var(--color-warning)]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
{runningCount} {t('teams.running')}
</span>
)}
<span
className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] transition-transform duration-200"
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
>
expand_less
</span>
</button>
{/* Expanded member list */}
{expanded && (
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
{members.map((member) => (
<MemberRow key={member.agentId} member={member} onView={() => openMemberSession(member)} />
))}
</div>
)}
</div>
</div>
)
}
function MemberRow({ member, onView }: { member: TeamMember; onView: () => void }) {
const config = memberStatusConfig[member.status] || memberStatusConfig.idle
return (
<button
onClick={onView}
className="w-full flex items-center gap-2 py-1.5 px-1 rounded-md text-left hover:bg-[var(--color-surface-container-low)] transition-colors group"
>
<span
className={`material-symbols-outlined text-[16px] shrink-0 ${config.pulse ? 'animate-pulse-dot' : ''}`}
style={{ color: config.color, fontVariationSettings: "'FILL' 1" }}
>
{config.icon}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className={`text-xs ${
member.status === 'completed'
? 'text-[var(--color-text-tertiary)]'
: 'text-[var(--color-text-primary)]'
}`}>
{member.role}
</span>
</div>
{member.status === 'running' && member.currentTask && (
<div className="flex items-center gap-1 mt-0.5">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
<span className="text-[10px] text-[var(--color-warning)] truncate">
{member.currentTask}
</span>
</div>
)}
</div>
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity">
open_in_new
</span>
</button>
)
}

View File

@ -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 (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="max-w-[720px] mx-auto">
{/* Viewing divider */}
<div className="flex items-center gap-3 mb-4">
<div className="flex-1 h-px bg-[var(--color-border)]" />
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('teams.viewing')} {member?.role || viewingAgentId} {t('teams.transcript')}
</span>
<div className="flex-1 h-px bg-[var(--color-border)]" />
</div>
{/* Transcript messages */}
{agentTranscript.length === 0 ? (
<div className="text-center text-sm text-[var(--color-text-tertiary)] py-8">
{t('teams.noMessages')}
</div>
) : (
agentTranscript.map((msg) => (
<div key={msg.id} className="mb-3 text-sm text-[var(--color-text-primary)]">
<pre className="whitespace-pre-wrap break-words font-[var(--font-mono)] text-xs bg-[var(--color-surface-info)] rounded-[var(--radius-md)] px-3 py-2">
{typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content, null, 2)}
</pre>
</div>
))
)}
</div>
</div>
)
}

View File

@ -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',

View File

@ -7,6 +7,7 @@ export const zh: Record<TranslationKey, string> = {
'common.delete': '删除',
'common.add': '添加',
'common.run': '运行',
'common.send': '发送',
'common.stop': '停止',
'common.rename': '重命名',
'common.retry': '重试',
@ -491,6 +492,12 @@ export const zh: Record<TranslationKey, string> = {
'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': '定时任务',

View File

@ -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(<ActiveSession />)
expect(queryByTestId('chat-input')).toBeInTheDocument()
expect(queryByTestId('session-task-bar')).not.toBeInTheDocument()
expect(fetchSessionTasks).not.toHaveBeenCalled()
unmount()
useCLITaskStore.setState(originalCliTaskState)
})
})

View File

@ -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 (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
<div className="min-w-0">
<div className="flex items-center gap-3">
{memberInfo?.status === 'running' && (
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
)}
{memberInfo?.status === 'completed' && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{memberInfo?.role}
</span>
{activeTeam && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
@ {activeTeam.name}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
</p>
</div>
<button
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
activeTeam.leadSessionId,
t('teams.leader'),
'session',
)
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</div>
</div>
)}
{isEmpty ? (
/* Welcome hero — same look as EmptySession */
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{t('empty.subtitle')}
</p>
{isMemberSession ? (
<>
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
<p className="text-[var(--color-text-secondary)]">
{memberInfo?.status === 'running'
? `${memberInfo.role} ${t('teams.working')}`
: t('teams.noMessages')}
</p>
</>
) : (
<>
<img src="/app-icon.jpg" alt="Claude Code Haha" className="mb-6 h-24 w-24 rounded-[22px] shadow-[0_2px_12px_rgba(0,0,0,0.06)]" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: "'Manrope', sans-serif" }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{t('empty.subtitle')}
</p>
</>
)}
</div>
</div>
) : (
<>
{/* Session info header */}
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
<div className="flex-1">
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
{session?.title || t('session.untitled')}
</h1>
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
{isActive && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
{!isMemberSession && (
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
<div className="flex-1">
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
{session?.title || t('session.untitled')}
</h1>
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
{isActive && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
</span>
</div>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
</span>
</div>
)}
</div>
</div>
)}
{/* Message stream */}
<MessageList />
</>
)}
{/* Session task bar — sticky at bottom */}
<SessionTaskBar />
{!isMemberSession && <SessionTaskBar />}
{/* Team status bar */}
<TeamStatusBar />
{/* Chat input */}
<ChatInput />
</div>
)

View File

@ -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 (
<span
className={`material-symbols-outlined ${className}`}
style={filled ? { fontVariationSettings: "'FILL' 1" } : undefined}
>
{name}
</span>
)
}
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 (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface selection:bg-primary-fixed">
{/* Context Banner */}
<div className="sticky top-0 z-30 bg-primary-container/10 backdrop-blur-md px-6 py-2 border-b border-primary/10 flex justify-between items-center">
<div className="flex items-center gap-3">
<span className="flex h-2 w-2 rounded-full bg-primary animate-pulse" />
<span className="text-sm font-medium text-primary">
Viewing: {agentName} transcript
</span>
</div>
<button
onClick={() => setViewingAgent(null)}
className="text-xs font-bold text-primary flex items-center gap-1 hover:underline cursor-pointer"
>
<Icon name="arrow_back" className="text-xs" />
Back to Leader
</button>
</div>
{/* Teammate Transcript Stream */}
<div className="flex-1 overflow-y-auto max-w-4xl mx-auto px-6 py-8 space-y-8 pb-24">
{messages.map((msg) => {
if (msg.role === 'agent') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 rounded-lg bg-surface-container-high shrink-0 flex items-center justify-center">
<Icon name="smart_toy" className="text-secondary" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-on-surface">{agentName}</span>
<span className="text-[10px] text-outline font-mono">{msg.timestamp}</span>
</div>
<div className="p-5 bg-surface-container-lowest rounded-xl border border-outline-variant/15 shadow-sm">
<p className="text-on-surface leading-relaxed text-sm">{msg.content}</p>
</div>
</div>
</div>
)
}
if (msg.role === 'tool') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
<Icon name="terminal" className="text-outline" />
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between text-[10px] uppercase tracking-widest text-outline font-bold">
<span>Tool Call: {msg.toolName?.toLowerCase()}</span>
<span className="bg-tertiary-container text-on-tertiary-container px-2 py-0.5 rounded-full">
{msg.status}
</span>
</div>
<div className="rounded-lg overflow-hidden bg-surface-dim border border-outline-variant/20">
<div className="flex items-center gap-2 px-4 py-2 bg-on-surface/5 border-b border-outline-variant/10">
<div className="w-2 h-2 rounded-full bg-error/40" />
<div className="w-2 h-2 rounded-full bg-primary-container/40" />
<div className="w-2 h-2 rounded-full bg-tertiary-container/40" />
<span className="ml-2 text-[10px] font-mono text-outline">
~/projects/companion/src/components
</span>
</div>
<pre className="p-4 text-xs font-mono text-on-surface leading-5 overflow-x-auto">
<code>{msg.command}</code>
</pre>
</div>
</div>
</div>
)
}
if (msg.role === 'progress') {
return (
<div key={msg.id} className="flex gap-4 items-start">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
<Icon name="insights" className="text-outline" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-3 p-4 bg-surface-container-low rounded-lg border border-outline-variant/5">
<Icon name="sync" className="text-primary text-lg" filled />
<div className="flex-1">
<p className="text-xs font-medium text-on-surface">{msg.label}</p>
<div className="w-full bg-surface-variant h-1 rounded-full mt-2 overflow-hidden">
<div
className="bg-primary h-full rounded-full"
style={{ width: `${msg.progress}%` }}
/>
</div>
</div>
<span className="text-[10px] font-mono text-outline">{msg.progress}%</span>
</div>
</div>
</div>
)
}
return null
})}
</div>
{/* ── Team Strip (Persistent Navigation) ── */}
<div className="absolute bottom-4 left-4 right-4 bg-surface-container-lowest/80 backdrop-blur-xl border border-outline-variant/20 rounded-2xl p-2 px-4 flex items-center gap-4 shadow-2xl z-10">
<div className="text-[10px] font-bold text-outline uppercase tracking-tighter pr-4 border-r border-outline-variant/20">
The Team
</div>
<div className="flex items-center gap-2">
{teamMembers.map((member) => (
<button
key={member.id}
onClick={() => setViewingAgent(member.id)}
className={`flex items-center gap-2 p-1.5 px-3 rounded-xl transition-colors ${
member.active
? 'bg-primary-fixed text-on-primary-fixed'
: 'hover:bg-surface-container-high'
}`}
>
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
member.active ? 'bg-on-primary-fixed/10' : 'bg-surface-container-high'
}`}>
<Icon name="smart_toy" className="text-xs" />
</div>
<span className={`text-xs ${member.active ? 'font-bold' : 'font-semibold'}`}>
{member.role}
</span>
{member.active && (
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
)}
</button>
))}
</div>
<div className="ml-auto flex items-center gap-4">
<span className="text-[10px] text-outline italic">Teammate is thinking...</span>
<button
onClick={() => setViewingAgent(null)}
className="w-8 h-8 rounded-full bg-on-surface text-surface flex items-center justify-center hover:scale-105 transition-transform"
>
<Icon name="arrow_back" className="text-sm" />
</button>
</div>
</div>
</div>
)
}

View File

@ -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: '<teammate-message teammate_id="security-reviewer">Review the auth diff and call out risks.</teammate-message>',
},
]
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,
})
})
})

View File

@ -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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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<ChatStore>((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('<teammate-message') && text.includes('</teammate-message>')
}
const TEAMMATE_CONTENT_REGEX = /<teammate-message\s+teammate_id="([^"]+)"[^>]*>\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<string, unknown>
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-message>
* teammate_ids found in subsequent user messages.
*/
export function reconstructAgentNotifications(messages: MessageEntry[]): Record<string, AgentTaskNotification> {
// Step 1: Collect Agent tool_use blocks → map agent name to toolUseId
const agentNameToToolUseId = new Map<string, string>()
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<string, unknown> | 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 <teammate-message> 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<string, string>()
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('<teammate-message')) continue
for (const match of text.matchAll(TEAMMATE_CONTENT_REGEX)) {
if (match[1] && match[2]) {
const content = match[2].trim()
// Skip lifecycle JSON messages (shutdown, idle, terminated notifications)
if (content.startsWith('{') && content.endsWith('}')) {
try {
const parsed = JSON.parse(content) as Record<string, unknown>
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<string, AgentTaskNotification> = {}
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 })

View File

@ -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<typeof setInterval> | 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<string, unknown>): 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<UIMessage, { type: 'user_text' }> & { pending: true } {
return message.type === 'user_text' && message.pending === true
}
function transcriptAlreadyContainsMessage(
transcriptMessages: UIMessage[],
pendingMessage: Extract<UIMessage, { type: 'user_text' }> & { 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<string, AgentColor>
error: string | null
fetchTeams: () => Promise<void>
fetchTeamDetail: (name: string) => Promise<void>
fetchMemberTranscript: (teamName: string, agentId: string) => Promise<void>
setViewingAgent: (agentId: string | null) => void
getMemberBySessionId: (sessionId: string) => TeamMember | null
refreshMemberSession: (sessionId: string) => Promise<void>
openMemberSession: (member: TeamMember) => void
sendMessageToMember: (sessionId: string, content: string) => Promise<void>
startMemberPolling: (sessionId: string, force?: boolean) => void
stopMemberPolling: () => void
clearTeam: () => void
// WebSocket handlers
@ -27,8 +140,6 @@ type TeamStore = {
export const useTeamStore = create<TeamStore>((set, get) => ({
teams: [],
activeTeam: null,
viewingAgentId: null,
agentTranscript: [],
memberColors: new Map(),
error: null,
@ -45,7 +156,16 @@ export const useTeamStore = create<TeamStore>((set, get) => ({
fetchTeamDetail: async (name: string) => {
set({ error: null })
try {
const detail = await teamsApi.get(name)
const raw = await teamsApi.get(name) as Record<string, unknown>
const rawMembers = Array.isArray(raw.members) ? raw.members : []
const members: TeamMember[] = rawMembers.map((m: Record<string, unknown>) => 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<string, AgentColor>()
detail.members.forEach((m, i) => {
@ -57,52 +177,164 @@ export const useTeamStore = create<TeamStore>((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<typeof mapHistoryMessagesToUiMessages>[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,

View File

@ -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 }

View File

@ -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',
])

View File

@ -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', () => {

View File

@ -59,6 +59,20 @@ async function writeTranscriptFile(
return filePath
}
async function writeSubagentTranscriptFile(
projectDir: string,
leadSessionId: string,
fileName: string,
entries: Record<string, unknown>[],
): Promise<string> {
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<string, unknown>) {
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<Record<string, unknown>> }).members) {

View File

@ -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)

View File

@ -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<TranscriptMessage[]> {
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<void> {
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<string[]> {
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<string | null> {
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<string[]> {
const projectsDir = this.getProjectsDir()
try {
await fs.access(projectsDir)
} catch {
return []
}
const discovered = new Set<string>()
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 `<teammate-message>` content (e.g., "你是 **security-reviewer**").
*/
private async findSubagentTranscript(
leadSessionId: string,
memberName: string,
): Promise<string | null> {
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<string> {
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<string | null> {
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<string, unknown>
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)

View File

@ -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<string, unknown>,
): TeamMemberStatus[] {
const inboxDir = path.join(teamsDir, teamName, 'inboxes')
const configMembers = Array.isArray(config.members) ? config.members : []
const configNames = new Set(
configMembers.map((m: Record<string, unknown>) => 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<string, unknown>,
): 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<string, unknown>) => m.name as string),
)
const projectsDir = path.join(path.dirname(teamsDir), 'projects')
try {
const projectEntries = fs.readdirSync(projectsDir, { withFileTypes: true })
const extra = new Map<string, TeamMemberStatus>()
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<string, unknown>
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 {

View File

@ -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().

View File

@ -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<TeamFile> {
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.