diff --git a/src/tools/agent-teams/team-config-store.test.ts b/src/tools/agent-teams/team-config-store.test.ts index b908a9e0..ee149401 100644 --- a/src/tools/agent-teams/team-config-store.test.ts +++ b/src/tools/agent-teams/team-config-store.test.ts @@ -8,6 +8,8 @@ import { getTeamDir, getTeamTaskDir, getTeamsRootDir } from "./paths" import { createTeamConfig, deleteTeamData, + deleteTeamDir, + listTeams, readTeamConfigOrThrow, teamExists, upsertTeammate, @@ -17,17 +19,27 @@ import { describe("agent-teams team config store", () => { let originalCwd: string let tempProjectDir: string + let createdTeams: string[] beforeEach(() => { originalCwd = process.cwd() tempProjectDir = mkdtempSync(join(tmpdir(), "agent-teams-config-store-")) process.chdir(tempProjectDir) - createTeamConfig("core", "Core team", "ses-main", tempProjectDir, "sisyphus") + createdTeams = [] + const timestamp = Date.now() + createTeamConfig(`core-${timestamp}`, "Core team", `ses-main-${timestamp}`, tempProjectDir, "sisyphus") + createdTeams.push(`core-${timestamp}`) }) afterEach(() => { - if (teamExists("core")) { - deleteTeamData("core") + for (const teamName of createdTeams) { + if (teamExists(teamName)) { + try { + deleteTeamData(teamName) + } catch { + // Ignore cleanup errors + } + } } process.chdir(originalCwd) rmSync(tempProjectDir, { recursive: true, force: true }) @@ -35,54 +47,57 @@ describe("agent-teams team config store", () => { test("deleteTeamData waits for team lock before removing team files", () => { //#given - const lock = acquireLock(getTeamDir("core")) + const teamName = createdTeams[0] + const lock = acquireLock(getTeamDir(teamName)) expect(lock.acquired).toBe(true) try { //#when - const deleteWhileLocked = () => deleteTeamData("core") + const deleteWhileLocked = () => deleteTeamData(teamName) //#then expect(deleteWhileLocked).toThrow("team_lock_unavailable") - expect(teamExists("core")).toBe(true) + expect(teamExists(teamName)).toBe(true) } finally { //#when lock.release() } - deleteTeamData("core") + deleteTeamData(teamName) //#then - expect(teamExists("core")).toBe(false) + expect(teamExists(teamName)).toBe(false) }) test("deleteTeamData waits for task lock before removing task files", () => { //#given - const lock = acquireLock(getTeamTaskDir("core")) + const teamName = createdTeams[0] + const lock = acquireLock(getTeamTaskDir(teamName)) expect(lock.acquired).toBe(true) try { //#when - const deleteWhileLocked = () => deleteTeamData("core") + const deleteWhileLocked = () => deleteTeamData(teamName) //#then expect(deleteWhileLocked).toThrow("team_task_lock_unavailable") - expect(teamExists("core")).toBe(true) + expect(teamExists(teamName)).toBe(true) } finally { lock.release() } //#when - deleteTeamData("core") + deleteTeamData(teamName) //#then - expect(teamExists("core")).toBe(false) + expect(teamExists(teamName)).toBe(false) }) test("deleteTeamData removes task files before deleting team directory", () => { //#given - const taskDir = getTeamTaskDir("core") - const teamDir = getTeamDir("core") + const teamName = createdTeams[0] + const taskDir = getTeamTaskDir(teamName) + const teamDir = getTeamDir(teamName) const teamsRootDir = getTeamsRootDir() expect(existsSync(taskDir)).toBe(true) expect(existsSync(teamDir)).toBe(true) @@ -90,7 +105,7 @@ describe("agent-teams team config store", () => { //#when chmodSync(teamsRootDir, 0o555) try { - const deleteWithBlockedTeamParent = () => deleteTeamData("core") + const deleteWithBlockedTeamParent = () => deleteTeamData(teamName) expect(deleteWithBlockedTeamParent).toThrow() } finally { chmodSync(teamsRootDir, 0o755) @@ -103,39 +118,40 @@ describe("agent-teams team config store", () => { test("deleteTeamData fails if team has active teammates", () => { //#given - const config = readTeamConfigOrThrow("core") + const teamName = createdTeams[0] + const config = readTeamConfigOrThrow(teamName) const updated = upsertTeammate(config, { - agentId: "teammate@core", + agentId: `teammate@${teamName}`, name: "teammate", - agentType: "sisyphus", + agentType: "teammate", category: "test", model: "sisyphus", prompt: "test prompt", color: "#000000", planModeRequired: false, - joinedAt: Date.now(), + joinedAt: new Date().toISOString(), cwd: process.cwd(), subscriptions: [], backendType: "native", isActive: true, sessionID: "ses-sub", }) - writeTeamConfig("core", updated) + writeTeamConfig(teamName, updated) //#when - const deleteWithTeammates = () => deleteTeamData("core") + const deleteWithTeammates = () => deleteTeamData(teamName) //#then expect(deleteWithTeammates).toThrow("team_has_active_members") - expect(teamExists("core")).toBe(true) + expect(teamExists(teamName)).toBe(true) //#when - cleanup teammate to allow afterEach to succeed const cleared = { ...updated, members: updated.members.filter(m => m.name === "team-lead") } - writeTeamConfig("core", cleared) - deleteTeamData("core") + writeTeamConfig(teamName, cleared) + deleteTeamData(teamName) //#then - expect(teamExists("core")).toBe(false) + expect(teamExists(teamName)).toBe(false) }) }) diff --git a/src/tools/agent-teams/team-config-store.ts b/src/tools/agent-teams/team-config-store.ts index 6667a807..085ca04c 100644 --- a/src/tools/agent-teams/team-config-store.ts +++ b/src/tools/agent-teams/team-config-store.ts @@ -1,4 +1,4 @@ -import { existsSync, rmSync } from "node:fs" +import { existsSync, readdirSync, rmSync } from "node:fs" import { acquireLock, ensureDir, readJsonSafe, writeJsonAtomic } from "../../features/claude-tasks/storage" import { getTeamConfigPath, @@ -20,8 +20,8 @@ import { import { validateTeamName } from "./name-validation" import { withTeamTaskLock } from "./team-task-store" -function nowMs(): number { - return Date.now() +function nowMs(): string { + return new Date().toISOString() } function assertValidTeamName(teamName: string): void { @@ -52,6 +52,7 @@ function createLeadMember(teamName: string, cwd: string, leadModel: string): Tea agentId: `team-lead@${teamName}`, name: "team-lead", agentType: "team-lead", + color: "#2D3748", model: leadModel, joinedAt: nowMs(), cwd, @@ -193,3 +194,24 @@ export function deleteTeamData(teamName: string): void { }) }) } + +export function deleteTeamDir(teamName: string): void { + deleteTeamData(teamName) +} + +export function listTeams(): string[] { + const teamsRootDir = getTeamsRootDir() + if (!existsSync(teamsRootDir)) { + return [] + } + + try { + const entries = readdirSync(teamsRootDir, { withFileTypes: true }) + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => existsSync(getTeamConfigPath(name))) + } catch { + return [] + } +} diff --git a/src/tools/agent-teams/types.ts b/src/tools/agent-teams/types.ts index be52a4fa..e22820f1 100644 --- a/src/tools/agent-teams/types.ts +++ b/src/tools/agent-teams/types.ts @@ -5,12 +5,37 @@ import { TaskObjectSchema } from "../task/types" export const TeamMemberSchema = z.object({ agentId: z.string().min(1), name: z.string().min(1), - agentType: z.enum(["lead", "teammate"]), + agentType: z.enum(["team-lead", "lead", "teammate"]), color: z.string().min(1), }) export type TeamMember = z.infer +export type TeamLeadMember = TeamMember & { + agentType: "team-lead" + model: string + joinedAt: string + cwd: string + subscriptions: string[] +} + +export function isTeammateMember(member: TeamMember): member is TeamTeammateMember { + return member.agentType === "teammate" +} + +export const TEAM_COLOR_PALETTE = [ + "#FF6B6B", // Red + "#4ECDC4", // Teal + "#45B7D1", // Blue + "#96CEB4", // Sage + "#FFEEAD", // Yellow + "#FF9F43", // Orange + "#6C5CE7", // Purple + "#00CEC9", // Cyan + "#F368E0", // Pink + "#FD7272", // Coral +] as const + export const TeamTeammateMemberSchema = TeamMemberSchema.extend({ category: z.string().min(1), model: z.string().min(1),