| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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' |
|
|
| |
|
|
| 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 |
| } |
|
|
| |
| 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 |
| }> |
| } |
|
|
| |
|
|
| 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') |
| } |
|
|
| |
|
|
| async listTeams(): Promise<TeamSummary[]> { |
| 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) |
| |
| 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 |
| teams.push(summary) |
| } catch { |
| |
| } |
| } |
|
|
| return teams |
| } |
|
|
| |
|
|
| async getTeam(name: string): Promise<TeamDetail> { |
| 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, |
| })) |
|
|
| |
| 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', |
| 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, |
| } |
| } |
|
|
| |
|
|
| async getMemberTranscript( |
| teamName: string, |
| agentId: string, |
| ): Promise<TranscriptMessage[]> { |
| 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}`, |
| ) |
| } |
|
|
| |
| 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) |
| } |
| } |
|
|
| |
| 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<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, |
| ) |
| } |
|
|
| |
|
|
| async deleteTeam(name: string): Promise<void> { |
| 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 }) |
| } |
|
|
| |
|
|
| private async loadTeamConfig(name: string): Promise<TeamFileRaw> { |
| 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}`) |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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, |
| ).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' |
| |
| 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] |
| } |
|
|
| |
| private async findTranscriptFile( |
| sessionId: 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 entry of projectEntries) { |
| if (!entry.isDirectory()) continue |
|
|
| const candidate = path.join(projectsDir, entry.name, `${sessionId}.jsonl`) |
| try { |
| await fs.access(candidate) |
| return candidate |
| } catch { |
| |
| } |
| } |
|
|
| return null |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| 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 { |
| |
| } |
| } |
|
|
| 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 { |
| |
| } |
| } |
|
|
| 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 |
| } |
| } |
|
|
| |
| private async parseTranscriptFile( |
| filePath: string, |
| ): Promise<TranscriptMessage[]> { |
| 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<string, unknown> |
|
|
| |
| const entryType = entry.type as string | undefined |
| if ( |
| entryType !== 'user' && |
| entryType !== 'assistant' && |
| entryType !== 'system' && |
| entryType !== 'tool_use' && |
| entryType !== 'tool_result' |
| ) { |
| continue |
| } |
|
|
| |
| 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 { |
| |
| } |
| } |
|
|
| return messages |
| } |
| } |
|
|
| export const teamService = new TeamService() |
|
|