/** * TeamService — 读取 CLI 生成的 Agent Teams 配置 * * Team 配置存储在 ~/.claude/teams/{name}/config.json * 成员 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 ───────────────────────────────────────────────────────────────── export type TeamMember = { agentId: string name: string agentType?: string model?: string color?: string backendType?: string status: 'running' | 'completed' | 'idle' | 'failed' joinedAt: number cwd: string sessionId?: string } export type TeamSummary = { name: string description?: string createdAt: number memberCount: number activeMemberCount: number } export type TeamDetail = TeamSummary & { leadAgentId: string leadSessionId?: string members: TeamMember[] } export type TranscriptMessage = { id: string type: 'user' | 'assistant' | 'system' | 'tool_use' | 'tool_result' content: unknown timestamp: string model?: string parentToolUseId?: string } /** Raw config.json structure written by CLI */ type TeamFileRaw = { name: string description?: string createdAt: number leadAgentId: string leadSessionId?: string members: Array<{ agentId: string name: string agentType?: string model?: string prompt?: string color?: string joinedAt: number tmuxPaneId: string cwd: string worktreePath?: string sessionId?: string backendType?: string isActive?: boolean mode?: string }> } // ─── Service ─────────────────────────────────────────────────────────────── export class TeamService { private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } private getTeamsDir(): string { return path.join(this.getConfigDir(), 'teams') } private getProjectsDir(): string { return path.join(this.getConfigDir(), 'projects') } // ── List all teams ────────────────────────────────────────────────────── async listTeams(): Promise { const teamsDir = this.getTeamsDir() try { await fs.access(teamsDir) } catch { return [] } const entries = await fs.readdir(teamsDir, { withFileTypes: true }) const teams: TeamSummary[] = [] for (const entry of entries) { if (!entry.isDirectory()) continue try { const config = await this.loadTeamConfig(entry.name) // 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 } } return teams } // ── Get team detail ───────────────────────────────────────────────────── async getTeam(name: string): Promise { const config = await this.loadTeamConfig(name) const members: TeamMember[] = config.members.map((m) => ({ agentId: m.agentId, name: m.name, 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, } } // ── Get member transcript ─────────────────────────────────────────────── async getMemberTranscript( teamName: string, agentId: string, ): Promise { const config = await this.loadTeamConfig(teamName) const memberName = await this.resolveMemberName(config, teamName, agentId) if (!memberName) { throw ApiError.notFound( `Team member not found: ${agentId} in team ${teamName}`, ) } // 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) } } // 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 [] } async sendMemberMessage( teamName: string, agentId: string, content: string, ): Promise { const text = content.trim() if (!text) { throw ApiError.badRequest('content (string) is required in request body') } const config = await this.loadTeamConfig(teamName) const recipientName = await this.resolveMemberName( config, teamName, agentId, ) if (!recipientName) { throw ApiError.notFound( `Team member not found: ${agentId} in team ${teamName}`, ) } await writeToMailbox( recipientName, { from: 'user', text, timestamp: new Date().toISOString(), }, teamName, ) } // ── Delete team ───────────────────────────────────────────────────────── async deleteTeam(name: string): Promise { const config = await this.loadTeamConfig(name) const hasActive = config.members.some( (m) => m.isActive === undefined || m.isActive === true, ) if (hasActive) { throw ApiError.conflict( `Cannot delete team "${name}": has active members`, ) } const teamDir = path.join(this.getTeamsDir(), name) await fs.rm(teamDir, { recursive: true, force: true }) } // ── Internal helpers ──────────────────────────────────────────────────── private async loadTeamConfig(name: string): Promise { const configPath = path.join(this.getTeamsDir(), name, 'config.json') try { const raw = await fs.readFile(configPath, 'utf-8') return JSON.parse(raw) as TeamFileRaw } catch { throw ApiError.notFound(`Team not found: ${name}`) } } /** * Discover member names from the inboxes/ directory. * Each file `{name}.json` in inboxes/ represents a team member. * Excludes the team-lead inbox since the leader is already in config. */ private async discoverInboxMembers(teamName: string): Promise { const inboxDir = path.join(this.getTeamsDir(), teamName, 'inboxes') try { const files = await fs.readdir(inboxDir) return files .filter((f) => f.endsWith('.json')) .map((f) => f.replace(/\.json$/, '')) .filter((name) => name !== 'team-lead') } catch { return [] } } private toSummary(config: TeamFileRaw): TeamSummary { const activeMemberCount = config.members.filter( (m) => m.isActive === undefined || m.isActive === true, ).length return { name: config.name, description: config.description, createdAt: config.createdAt, memberCount: config.members.length, activeMemberCount, } } private deriveStatus( isActive: boolean | undefined, ): 'running' | 'completed' | 'idle' | 'failed' { if (isActive === false) return 'idle' // isActive === undefined || isActive === true return 'running' } private async resolveMemberName( config: TeamFileRaw, teamName: string, agentId: string, ): Promise { const configMember = config.members.find((m) => m.agentId === agentId) if (configMember?.name) { return configMember.name } const parsedName = agentId.includes('@') ? agentId.split('@')[0]! : agentId const inboxNames = await this.discoverInboxMembers(teamName) if (inboxNames.includes(parsedName)) { return parsedName } if (config.leadSessionId) { const subagentNames = await this.discoverSubagentMembers( config.leadSessionId, ) if (subagentNames.includes(parsedName)) { return parsedName } } return null } private async discoverSubagentMembers(leadSessionId: string): Promise { const projectsDir = this.getProjectsDir() try { await fs.access(projectsDir) } catch { return [] } const discovered = new Set() const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true, }) for (const projEntry of projectEntries) { if (!projEntry.isDirectory()) continue const subagentsDir = path.join( projectsDir, projEntry.name, leadSessionId, 'subagents', ) let files: string[] try { files = await fs.readdir(subagentsDir) } catch { continue } for (const file of files) { if (!file.endsWith('.jsonl')) continue const discoveredName = await this.extractSubagentName( path.join(subagentsDir, file), ) if (discoveredName && discoveredName !== 'team-lead') { discovered.add(discoveredName) } } } return [...discovered] } /** Search ~/.claude/projects/ for a JSONL file matching the sessionId. */ private async findTranscriptFile( sessionId: string, ): Promise { const projectsDir = this.getProjectsDir() try { await fs.access(projectsDir) } catch { return null } const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true, }) for (const entry of projectEntries) { if (!entry.isDirectory()) continue const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`) try { await fs.access(candidate) return candidate } catch { // Not in this project directory } } return null } /** * Search subagents directory for a specific member's transcript. * Path: ~/.claude/projects/{project}/{leadSessionId}/subagents/agent-*.jsonl * * Matches by reading the first user message and checking for the member name * in the `` content (e.g., "你是 **security-reviewer**"). */ private async findSubagentTranscript( leadSessionId: string, memberName: string, ): Promise { const projectsDir = this.getProjectsDir() try { await fs.access(projectsDir) } catch { return null } const projectEntries = await fs.readdir(projectsDir, { withFileTypes: true, }) for (const projEntry of projectEntries) { if (!projEntry.isDirectory()) continue const subagentsDir = path.join( projectsDir, projEntry.name, leadSessionId, 'subagents', ) let files: string[] try { files = await fs.readdir(subagentsDir) } catch { continue } // Check each subagent file — find the one matching this member // Use the most recent file if multiple match (handles retries) let bestMatch: { path: string; mtime: number } | null = null for (const file of files) { if (!file.endsWith('.jsonl')) continue const filePath = path.join(subagentsDir, file) try { const head = await this.readTranscriptHead(filePath) if ( head.includes(`"${memberName}"`) || head.includes(`**${memberName}**`) || head.includes(`name":"${memberName}`) || (await this.extractSubagentName(filePath)) === memberName ) { const stat = await fs.stat(filePath) if (!bestMatch || stat.mtimeMs > bestMatch.mtime) { bestMatch = { path: filePath, mtime: stat.mtimeMs } } } } catch { // Skip unreadable files } } if (bestMatch) { return bestMatch.path } } return null } private async readTranscriptHead(filePath: string): Promise { const fd = await fs.open(filePath, 'r') try { const buf = Buffer.alloc(8192) const { bytesRead } = await fd.read(buf, 0, 8192, 0) return buf.toString('utf-8', 0, bytesRead) } finally { await fd.close() } } private async extractSubagentName(filePath: string): Promise { try { const head = await this.readTranscriptHead(filePath) const lines = head.split('\n').filter((line) => line.trim().length > 0) for (const line of lines) { try { const entry = JSON.parse(line) as Record if (typeof entry.agentName === 'string' && entry.agentName.trim()) { return entry.agentName } if (typeof entry.agentId === 'string' && entry.agentId.includes('@')) { return entry.agentId.split('@')[0] ?? null } } catch { // Ignore partial or non-JSON lines in the preview window. } } const nameMatch = head.match(/"agentName"\s*:\s*"([^"]+)"/) || head.match(/"name"\s*:\s*"([^"]+)"/) || head.match(/\*\*([a-zA-Z0-9_-]+)\*\*/) return nameMatch?.[1] ?? null } catch { return null } } /** Parse a JSONL transcript file into messages. */ private async parseTranscriptFile( filePath: string, ): Promise { const raw = await fs.readFile(filePath, 'utf-8') const lines = raw.split('\n').filter((line) => line.trim().length > 0) const messages: TranscriptMessage[] = [] for (const line of lines) { try { const entry = JSON.parse(line) as Record // Skip non-message entries (snapshots, meta, etc.) const entryType = entry.type as string | undefined if ( entryType !== 'user' && entryType !== 'assistant' && entryType !== 'system' && entryType !== 'tool_use' && entryType !== 'tool_result' ) { continue } // Skip meta entries if (entry.isMeta) continue const message: TranscriptMessage = { id: (entry.uuid as string) || crypto.randomUUID(), type: entryType as TranscriptMessage['type'], 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) } catch { // Skip unparseable lines } } return messages } } export const teamService = new TeamService()