agent-ecosystem/src/main/services/team/TeamMemberResolver.ts

292 lines
9.3 KiB
TypeScript

import {
createCliAutoSuffixNameGuard,
createCliProvisionerNameGuard,
} from '@shared/utils/teamMemberName';
import type {
InboxMessage,
MemberStatus,
ResolvedTeamMember,
TeamConfig,
TeamMember,
TeamTaskWithKanban,
} from '@shared/types';
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
'cross_team_send',
'cross_team_list_targets',
'cross_team_get_outbox',
]);
const GENERATED_AGENT_ID_PATTERN = /^a[0-9a-f]{16}$/i;
function looksLikeQualifiedExternalRecipient(name: string): boolean {
const trimmed = name.trim();
const dot = trimmed.indexOf('.');
if (dot <= 0 || dot === trimmed.length - 1) return false;
const teamName = trimmed.slice(0, dot).trim();
const memberName = trimmed.slice(dot + 1).trim();
return TEAM_NAME_PATTERN.test(teamName) && memberName.length > 0;
}
function looksLikeCrossTeamPseudoRecipient(name: string): boolean {
const trimmed = name.trim();
const prefixes = [
'cross_team::',
'cross_team--',
'cross-team:',
'cross-team-',
'cross_team:',
'cross_team-',
];
for (const prefix of prefixes) {
if (!trimmed.startsWith(prefix)) continue;
const teamName = trimmed.slice(prefix.length).trim();
if (TEAM_NAME_PATTERN.test(teamName)) {
return true;
}
}
return false;
}
function looksLikeCrossTeamToolRecipient(name: string): boolean {
return CROSS_TEAM_TOOL_RECIPIENT_NAMES.has(name.trim());
}
function looksLikeGeneratedAgentId(name: string): boolean {
return GENERATED_AGENT_ID_PATTERN.test(name.trim());
}
export class TeamMemberResolver {
resolveMembers(
config: TeamConfig,
metaMembers: TeamConfig['members'],
inboxNames: string[],
tasks: TeamTaskWithKanban[],
messages: InboxMessage[]
): ResolvedTeamMember[] {
const names = new Set<string>();
const explicitNames = new Set<string>();
const seenNames = new Set<string>();
const addName = (name: string): void => {
const normalized = name.toLowerCase();
if (seenNames.has(normalized)) {
return;
}
seenNames.add(normalized);
names.add(name);
};
if (Array.isArray(config.members)) {
for (const member of config.members) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
const trimmed = member.name.trim();
addName(trimmed);
explicitNames.add(trimmed.toLowerCase());
}
}
}
if (Array.isArray(metaMembers)) {
for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
const trimmed = member.name.trim();
addName(trimmed);
explicitNames.add(trimmed.toLowerCase());
}
}
}
for (const inboxName of inboxNames) {
if (typeof inboxName === 'string' && inboxName.trim() !== '') {
const trimmed = inboxName.trim();
if (
looksLikeCrossTeamPseudoRecipient(trimmed) ||
looksLikeCrossTeamToolRecipient(trimmed)
) {
continue;
}
if (
!explicitNames.has(trimmed.toLowerCase()) &&
looksLikeQualifiedExternalRecipient(trimmed)
) {
continue;
}
if (!explicitNames.has(trimmed.toLowerCase()) && looksLikeGeneratedAgentId(trimmed)) {
continue;
}
addName(trimmed);
}
}
const configMemberMap = new Map<
string,
{
agentType?: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
color?: string;
cwd?: string;
}
>();
if (Array.isArray(config.members)) {
for (const m of config.members) {
if (typeof m?.name === 'string' && m.name.trim() !== '') {
const configMember = m as TeamMember & { provider?: 'anthropic' | 'codex' | 'gemini' };
const providerId =
configMember.providerId === 'anthropic' ||
configMember.providerId === 'codex' ||
configMember.providerId === 'gemini'
? configMember.providerId
: configMember.provider === 'anthropic' ||
configMember.provider === 'codex' ||
configMember.provider === 'gemini'
? configMember.provider
: undefined;
configMemberMap.set(m.name.trim(), {
agentType: configMember.agentType,
role: configMember.role,
workflow: configMember.workflow,
providerId,
model: configMember.model,
effort: configMember.effort,
color: configMember.color,
cwd: configMember.cwd,
});
}
}
}
const metaMemberMap = new Map<
string,
{
agentType?: string;
role?: string;
workflow?: string;
providerId?: 'anthropic' | 'codex' | 'gemini';
model?: string;
effort?: 'low' | 'medium' | 'high';
color?: string;
removedAt?: number;
}
>();
if (Array.isArray(metaMembers)) {
for (const member of metaMembers) {
if (typeof member?.name === 'string' && member.name.trim() !== '') {
metaMemberMap.set(member.name.trim(), {
agentType: member.agentType,
role: member.role,
workflow: member.workflow,
providerId: member.providerId,
model: member.model,
effort: member.effort,
color: member.color,
removedAt: member.removedAt,
});
}
}
}
// "user" is a built-in pseudo-member in Claude Code's team framework
// (recipient of SendMessage to "user"). It's not a real AI teammate.
names.delete('user');
// Defense: merge inbox-derived "lead" alias into canonical "team-lead".
// Teammates sometimes address messages to "lead" instead of "team-lead",
// creating a separate inbox file that the resolver picks up as a phantom member.
if (names.has('lead') && names.has('team-lead')) {
names.delete('lead');
}
// Defense: hide CLI auto-suffixed duplicates (alice-2) when base name (alice) exists.
const keepName = createCliAutoSuffixNameGuard(names);
// Defense: hide CLI provisioner artifacts (alice-provisioner) when base name (alice) exists.
const keepProvisioner = createCliProvisionerNameGuard(names);
for (const name of Array.from(names)) {
if (!keepName(name) || !keepProvisioner(name)) {
names.delete(name);
}
}
const members: ResolvedTeamMember[] = [];
for (const name of names) {
const ownedTasks = tasks.filter((task) => task.owner === name);
const currentTask =
ownedTasks.find(
(task) =>
task.status === 'in_progress' &&
task.reviewState !== 'approved' &&
task.kanbanColumn !== 'approved'
) ?? null;
const memberMessages = messages.filter((message) => message.from === name);
const latestMessage = memberMessages[0] ?? null;
const status = this.resolveStatus(latestMessage, currentTask !== null);
const configMember = configMemberMap.get(name);
const metaMember = metaMemberMap.get(name);
members.push({
name,
status,
currentTaskId: currentTask?.id ?? null,
taskCount: ownedTasks.length,
messageCount: memberMessages.length,
lastActiveAt: latestMessage?.timestamp ?? null,
color: latestMessage?.color ?? configMember?.color ?? metaMember?.color,
agentType: configMember?.agentType ?? metaMember?.agentType,
role: configMember?.role ?? metaMember?.role,
workflow: configMember?.workflow ?? metaMember?.workflow,
providerId: configMember?.providerId ?? metaMember?.providerId,
model: configMember?.model ?? metaMember?.model,
effort: configMember?.effort ?? metaMember?.effort,
cwd: configMember?.cwd,
removedAt: metaMember?.removedAt,
});
}
members.sort((a, b) => a.name.localeCompare(b.name));
return members;
}
private resolveStatus(message: InboxMessage | null, hasActiveTask: boolean): MemberStatus {
if (!message) {
// Member exists in config but has no messages yet —
// if they own an in_progress task they're clearly active, otherwise idle
return hasActiveTask ? 'active' : 'idle';
}
const structured = this.parseStructuredMessage(message.text);
if (structured) {
const typed = structured as { type?: string; approve?: boolean; approved?: boolean };
if (
(typed.type === 'shutdown_response' &&
(typed.approve === true || typed.approved === true)) ||
typed.type === 'shutdown_approved'
) {
return 'terminated';
}
}
const ageMs = Date.now() - Date.parse(message.timestamp);
if (Number.isNaN(ageMs)) {
return 'unknown';
}
if (ageMs < 5 * 60 * 1000) {
return 'active';
}
return 'idle';
}
private parseStructuredMessage(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore plain text.
}
return null;
}
}