diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 54267d67e4..88e4b73cf8 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -51,6 +51,11 @@ export enum RooCodeEventName { ModesResponse = "modesResponse", ModelsResponse = "modelsResponse", + // Swarm Lifecycle + SwarmSessionStarted = "swarmSessionStarted", + SwarmSessionEnded = "swarmSessionEnded", + WorkerRegistered = "workerRegistered", + // Evals EvalPass = "evalPass", EvalFail = "evalFail", @@ -112,6 +117,10 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskToolFailed]: z.tuple([z.string(), toolNamesSchema, z.string()]), [RooCodeEventName.TaskTokenUsageUpdated]: z.tuple([z.string(), tokenUsageSchema, toolUsageSchema]), + [RooCodeEventName.SwarmSessionStarted]: z.tuple([z.string(), z.string()]), // [sessionId, leaderTaskId] + [RooCodeEventName.SwarmSessionEnded]: z.tuple([z.string(), z.string()]), // [sessionId, leaderTaskId] + [RooCodeEventName.WorkerRegistered]: z.tuple([z.string(), z.string(), z.string(), z.string()]), // [sessionId, taskId, agentName, agentColor] + [RooCodeEventName.ModeChanged]: z.tuple([z.string()]), [RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]), @@ -274,6 +283,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ taskId: z.number().optional(), }), + // Swarm Lifecycle + z.object({ + eventName: z.literal(RooCodeEventName.SwarmSessionStarted), + payload: rooCodeEventsSchema.shape[RooCodeEventName.SwarmSessionStarted], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.SwarmSessionEnded), + payload: rooCodeEventsSchema.shape[RooCodeEventName.SwarmSessionEnded], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.WorkerRegistered), + payload: rooCodeEventsSchema.shape[RooCodeEventName.WorkerRegistered], + taskId: z.number().optional(), + }), + // Evals z.object({ eventName: z.literal(RooCodeEventName.EvalPass), diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index 6e7b47fe7f..e12743582d 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -50,6 +50,12 @@ export const historyItemSchema = z.object({ ) .optional(), // Accumulated results from completed parallel children abortOnChildFailure: z.boolean().optional(), // When true, the entire parallel queue is abandoned if any child fails + // Swarm identity — set by spawnConcurrentChildren for workers, and on the leader task + swarmSessionId: z.string().optional(), + agentId: z.string().optional(), // "@" + agentName: z.string().optional(), // Human-readable role label + agentColor: z.string().optional(), // AgentColorName + isSwarmLeader: z.boolean().optional(), }) export type HistoryItem = z.infer diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3b95c84d1a..361603f028 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -23,6 +23,7 @@ export * from "./provider-settings.js" export * from "./task.js" export * from "./todo.js" export * from "./skills.js" +export * from "./swarm.js" export * from "./team.js" export * from "./telemetry.js" export * from "./terminal.js" diff --git a/packages/types/src/swarm.ts b/packages/types/src/swarm.ts new file mode 100644 index 0000000000..bd02796043 --- /dev/null +++ b/packages/types/src/swarm.ts @@ -0,0 +1,55 @@ +/** + * Swarm / multi-agent identity types. + * These types describe agent identities, sessions, and the message envelope + * used between agents (mailbox protocol, implemented in Phase 3+). + */ + +export type AgentColorName = "red" | "blue" | "green" | "yellow" | "purple" | "orange" | "pink" | "cyan" + +/** + * Stable identity for a single agent within a swarm session. + */ +export interface AgentIdentity { + /** Stable unique identifier — "@" */ + agentId: string + /** Human-readable role name (e.g. "Backend Engineer") */ + agentName: string + /** Assigned display color */ + color: AgentColorName + /** True for the task that called spawnConcurrentChildren */ + isLeader: boolean + /** The Roo-Code taskId this identity is bound to */ + taskId: string +} + +/** + * In-memory session tracking all agents spawned by a single concurrent spawn call. + * Keyed by taskId in the `teammates` record. + */ +export interface SwarmSession { + /** Same as the leaderTaskId for simplicity */ + sessionId: string + /** The parent task that created this session */ + leaderTaskId: string + /** All registered worker identities, keyed by taskId */ + teammates: Record +} + +// --------------------------------------------------------------------------- +// Mailbox protocol (P3 stubs — types only, no implementation yet) +// --------------------------------------------------------------------------- + +export type TeammateMessageType = + | "task_assignment" + | "idle_notification" + | "shutdown_request" + | "permission_request" + | "permission_response" + +export interface TeammateMessage { + type: TeammateMessageType + from: string + to: string + payload?: Record + ts: number +} diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index a42e2607f6..4f6eb344ea 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -85,6 +85,10 @@ export type TaskProviderEvents = { [RooCodeEventName.ModeChanged]: [mode: string] [RooCodeEventName.ProviderProfileChanged]: [config: { name: string; provider?: string }] + + [RooCodeEventName.SwarmSessionStarted]: [sessionId: string, leaderTaskId: string] + [RooCodeEventName.SwarmSessionEnded]: [sessionId: string, leaderTaskId: string] + [RooCodeEventName.WorkerRegistered]: [sessionId: string, taskId: string, agentName: string, agentColor: string] } /** diff --git a/src/core/swarm/SwarmRegistry.ts b/src/core/swarm/SwarmRegistry.ts new file mode 100644 index 0000000000..73954f04b5 --- /dev/null +++ b/src/core/swarm/SwarmRegistry.ts @@ -0,0 +1,62 @@ +import type { AgentColorName, AgentIdentity, SwarmSession } from "@roo-code/types" + +const AGENT_COLORS: AgentColorName[] = ["blue", "green", "yellow", "purple", "orange", "pink", "cyan", "red"] + +/** + * In-memory registry that tracks swarm sessions and assigns stable identities + * (name + color) to concurrent worker agents. + * + * One instance lives on ClineProvider. Sessions are created when + * spawnConcurrentChildren() starts and destroyed when all children resolve. + */ +export class SwarmRegistry { + private sessions: Map = new Map() + private colorIndex = 0 + + createSession(sessionId: string, leaderTaskId: string): SwarmSession { + const session: SwarmSession = { sessionId, leaderTaskId, teammates: {} } + this.sessions.set(sessionId, session) + return session + } + + /** Round-robin color assignment, stable across the provider lifetime. */ + assignColor(): AgentColorName { + const color = AGENT_COLORS[this.colorIndex % AGENT_COLORS.length] + this.colorIndex++ + return color + } + + registerWorker(sessionId: string, identity: AgentIdentity): void { + const session = this.sessions.get(sessionId) + if (session) { + session.teammates[identity.taskId] = identity + } + } + + unregisterWorker(sessionId: string, taskId: string): void { + const session = this.sessions.get(sessionId) + if (session) { + delete session.teammates[taskId] + } + } + + getSession(sessionId: string): SwarmSession | undefined { + return this.sessions.get(sessionId) + } + + /** Find the session a given task belongs to (as a worker). */ + getSessionForTask(taskId: string): SwarmSession | undefined { + for (const session of this.sessions.values()) { + if (taskId in session.teammates) return session + } + return undefined + } + + destroySession(sessionId: string): void { + this.sessions.delete(sessionId) + } + + dispose(): void { + this.sessions.clear() + } +} diff --git a/src/core/tools/RunTeamPhaseTool.ts b/src/core/tools/RunTeamPhaseTool.ts index 2ca21a571e..639b2e0087 100644 --- a/src/core/tools/RunTeamPhaseTool.ts +++ b/src/core/tools/RunTeamPhaseTool.ts @@ -31,7 +31,7 @@ interface TeamPhaseProvider { }): Promise spawnConcurrentChildren(params: { parentTaskId: string - tasks: Array<{ mode: string; message: string; worktree?: string }> + tasks: Array<{ mode: string; message: string; worktree?: string; role?: string }> abortOnChildFailure?: boolean }): Promise> } @@ -127,7 +127,7 @@ export class RunTeamPhaseTool extends BaseTool<"run_team_phase"> { // Concurrent: all agents start simultaneously; parent stays alive. const results = await provider.spawnConcurrentChildren({ parentTaskId: task.taskId, - tasks: agentSpecs.map(({ mode, message, worktree }) => ({ mode, message, worktree })), + tasks: agentSpecs.map(({ mode, message, worktree, role }) => ({ mode, message, worktree, role })), abortOnChildFailure: phase.abortOnChildFailure ?? false, }) pushToolResult(JSON.stringify(results, null, 2)) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 436f9a5949..17ed434d22 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -78,6 +78,7 @@ import type { IndexProgressUpdate } from "../../services/code-index/interfaces/m import { MdmService } from "../../services/mdm/MdmService" import { SkillsManager } from "../../services/skills/SkillsManager" import { TeamsManager } from "../../services/teams/TeamsManager" +import { SwarmRegistry } from "../swarm/SwarmRegistry" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -154,6 +155,7 @@ export class ClineProvider protected mcpHub?: McpHub // Change from private to protected protected skillsManager?: SkillsManager protected teamsManager?: TeamsManager + protected swarmRegistry: SwarmRegistry = new SwarmRegistry() private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -741,6 +743,7 @@ export class ClineProvider await this.skillsManager?.dispose() this.skillsManager = undefined this.teamsManager = undefined + this.swarmRegistry.dispose() this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose() @@ -3823,7 +3826,7 @@ export class ClineProvider */ public async spawnConcurrentChildren(params: { parentTaskId: string - tasks: Array<{ mode: string; message: string; worktree?: string; todos?: TodoItem[] }> + tasks: Array<{ mode: string; message: string; worktree?: string; todos?: TodoItem[]; role?: string }> abortOnChildFailure?: boolean }): Promise; error?: string }>> { const { parentTaskId, tasks, abortOnChildFailure = false } = params @@ -3836,6 +3839,19 @@ export class ClineProvider TelemetryService.instance.captureParallelTaskSpawned(parentTaskId, tasks.length) this.log(`[spawnConcurrentChildren] Spawning ${tasks.length} concurrent children for parent ${parentTaskId}`) + // Create a swarm session so every worker gets a stable identity + color. + const sessionId = parentTaskId + this.swarmRegistry.createSession(sessionId, parentTaskId) + this.emit(RooCodeEventName.SwarmSessionStarted, sessionId, parentTaskId) + + // Mark parent as swarm leader in its history item. + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + await this.updateTaskHistory({ ...parentHistory, swarmSessionId: sessionId, isSwarmLeader: true }) + } catch { + // non-fatal + } + // Phase 1: sequential setup — mode switch and task creation must be serialised to // prevent handleModeSwitch() from racing (it mutates global provider mode state). // Task.start() is NOT called yet; that happens in phase 2. @@ -3851,7 +3867,9 @@ export class ClineProvider } const entries: ChildEntry[] = [] - for (const spec of tasks) { + for (let i = 0; i < tasks.length; i++) { + const spec = tasks[i] + try { await this.handleModeSwitch(spec.mode as any) } catch { @@ -3870,13 +3888,32 @@ export class ClineProvider workspacePath: childWorktreePath, }) - if (childWorktreePath) { - try { - const { historyItem: childHistory } = await this.getTaskWithId(child.taskId) - await this.updateTaskHistory({ ...childHistory, worktreePath: childWorktreePath }) - } catch { - // non-fatal - } + // Assign swarm identity to this worker. + const agentName = spec.role ?? `worker-${i + 1}` + const agentColor = this.swarmRegistry.assignColor() + const agentId = `${agentName}@${sessionId}` + const identity: import("@roo-code/types").AgentIdentity = { + agentId, + agentName, + color: agentColor, + isLeader: false, + taskId: child.taskId, + } + this.swarmRegistry.registerWorker(sessionId, identity) + this.emit(RooCodeEventName.WorkerRegistered, sessionId, child.taskId, agentName, agentColor) + + try { + const { historyItem: childHistory } = await this.getTaskWithId(child.taskId) + await this.updateTaskHistory({ + ...childHistory, + ...(childWorktreePath ? { worktreePath: childWorktreePath } : {}), + swarmSessionId: sessionId, + agentId, + agentName, + agentColor, + }) + } catch { + // non-fatal } const completionPromise = new Promise<{ @@ -3947,6 +3984,9 @@ export class ClineProvider `[spawnConcurrentChildren] All ${tasks.length} children completed for parent ${parentTaskId}. failures=${hadFailures}`, ) + this.swarmRegistry.destroySession(sessionId) + this.emit(RooCodeEventName.SwarmSessionEnded, sessionId, parentTaskId) + return results } diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index 0089e1f81d..deae0ff0ff 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -7,6 +7,17 @@ import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" +const AGENT_COLOR_MAP: Record = { + red: "#f87171", + blue: "#60a5fa", + green: "#4ade80", + yellow: "#facc15", + purple: "#c084fc", + orange: "#fb923c", + pink: "#f472b6", + cyan: "#22d3ee", +} + interface SubtaskRowProps { /** The subtask tree node to display */ node: SubtaskTreeNode @@ -49,9 +60,19 @@ const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) handleClick() } }}> - - {item.task} - +
+ {item.agentColor && AGENT_COLOR_MAP[item.agentColor] && ( + + + + )} + + {item.task} + +