diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index de31cd43e1..5c8cbe05ad 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -57,6 +57,8 @@ export enum RooCodeEventName { WorkerRegistered = "workerRegistered", WorkerIdle = "workerIdle", WorkerShutdown = "workerShutdown", + PermissionRequested = "permissionRequested", + PermissionResolved = "permissionResolved", // Evals EvalPass = "evalPass", @@ -124,6 +126,8 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.WorkerRegistered]: z.tuple([z.string(), z.string(), z.string(), z.string()]), // [sessionId, taskId, agentName, agentColor] [RooCodeEventName.WorkerIdle]: z.tuple([z.string(), z.string()]), // [sessionId, taskId] [RooCodeEventName.WorkerShutdown]: z.tuple([z.string(), z.string()]), // [sessionId, taskId] + [RooCodeEventName.PermissionRequested]: z.tuple([z.string(), z.string(), z.string()]), // [workerTaskId, toolName, requestId] + [RooCodeEventName.PermissionResolved]: z.tuple([z.string(), z.string(), z.boolean()]), // [workerTaskId, requestId, allowed] [RooCodeEventName.ModeChanged]: z.tuple([z.string()]), [RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]), @@ -313,6 +317,16 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.WorkerShutdown], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.PermissionRequested), + payload: rooCodeEventsSchema.shape[RooCodeEventName.PermissionRequested], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.PermissionResolved), + payload: rooCodeEventsSchema.shape[RooCodeEventName.PermissionResolved], + taskId: z.number().optional(), + }), // Evals z.object({ diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 2034e7c1dd..b65690cf5f 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -91,6 +91,8 @@ export type TaskProviderEvents = { [RooCodeEventName.WorkerRegistered]: [sessionId: string, taskId: string, agentName: string, agentColor: string] [RooCodeEventName.WorkerIdle]: [sessionId: string, taskId: string] [RooCodeEventName.WorkerShutdown]: [sessionId: string, taskId: string] + [RooCodeEventName.PermissionRequested]: [workerTaskId: string, toolName: string, requestId: string] + [RooCodeEventName.PermissionResolved]: [workerTaskId: string, requestId: string, allowed: boolean] } /** diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 9c16d0a257..98986bd4f6 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -2,7 +2,8 @@ import { serializeError } from "serialize-error" import { Anthropic } from "@anthropic-ai/sdk" import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types" -import { ConsecutiveMistakeError, TelemetryEventName } from "@roo-code/types" +import { ConsecutiveMistakeError, TelemetryEventName, RooCodeEventName } from "@roo-code/types" +import { submitWorkerPermissionRequest } from "../swarm/LeaderPermissionBridge" import { TelemetryService } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -504,6 +505,49 @@ export async function presentAssistantMessage(cline: Task) { progressStatus?: ToolProgressStatus, isProtected?: boolean, ) => { + // Concurrent workers route tool-approval through the leader permission bridge + // instead of surfacing a UI prompt on the worker task itself. + if (type === "tool" && cline.parentTaskId) { + type PermissionProvider = { + getTaskById?: (id: string) => unknown + swarmRegistry?: { + getSessionForTask( + taskId: string, + ): + | { + sessionId: string + teammates: Record< + string, + { agentName: string; color: import("@roo-code/types").AgentColorName } + > + } + | undefined + } + } + const provider = cline.providerRef.deref() as PermissionProvider | undefined + const parentIsAlive = provider?.getTaskById?.(cline.parentTaskId) !== undefined + if (parentIsAlive) { + const session = provider?.swarmRegistry?.getSessionForTask(cline.taskId) + const identity = session?.teammates[cline.taskId] + const agentName = identity?.agentName ?? "Worker" + const color = identity?.color ?? ("blue" as import("@roo-code/types").AgentColorName) + const toolName = String(block.name ?? "tool") + const allowed = await submitWorkerPermissionRequest( + cline.taskId, + agentName, + color, + toolName, + partialMessage ?? "", + ) + if (!allowed) { + pushToolResult(formatResponse.toolDenied()) + cline.didRejectTool = true + return false + } + return true + } + } + const { response, text, images } = await cline.ask( type, partialMessage, diff --git a/src/core/swarm/LeaderPermissionBridge.ts b/src/core/swarm/LeaderPermissionBridge.ts new file mode 100644 index 0000000000..0a0d233294 --- /dev/null +++ b/src/core/swarm/LeaderPermissionBridge.ts @@ -0,0 +1,81 @@ +import * as vscode from "vscode" +import { randomUUID } from "crypto" + +import type { AgentColorName } from "@roo-code/types" + +/** + * A pending tool-approval request from a concurrent worker agent. + */ +export interface WorkerPermissionRequest { + requestId: string + workerTaskId: string + agentName: string + color: AgentColorName + /** Canonical tool name (e.g. "write_to_file") */ + toolName: string + /** Human-readable description shown in the approval dialog */ + description: string +} + +type PermissionHandler = (req: WorkerPermissionRequest) => Promise + +// Module-level singleton — one ClineProvider registers itself; all in-process +// worker tasks can reach it without needing a direct reference. +let activeHandler: PermissionHandler | null = null + +/** + * Called by ClineProvider to register itself as the approval surface. + * Returns a cleanup function that deregisters the handler on dispose. + */ +export function registerPermissionHandler(handler: PermissionHandler): () => void { + activeHandler = handler + return () => { + if (activeHandler === handler) activeHandler = null + } +} + +/** True when a ClineProvider has registered a handler. */ +export function hasPermissionHandler(): boolean { + return activeHandler !== null +} + +/** + * Called by concurrent workers when they need tool-use approval. + * Routes the request to the registered leader handler. + * Returns false (deny) when no handler is registered — safe fail-closed. + */ +export async function submitWorkerPermissionRequest( + workerTaskId: string, + agentName: string, + color: AgentColorName, + toolName: string, + description: string, +): Promise { + if (!activeHandler) return false + const req: WorkerPermissionRequest = { + requestId: randomUUID(), + workerTaskId, + agentName, + color, + toolName, + description, + } + return activeHandler(req) +} + +/** + * Default VS Code approval handler used by ClineProvider. + * Shows a modal dialog with the worker's identity and tool details. + */ +export async function showWorkerPermissionDialog(req: WorkerPermissionRequest): Promise { + const label = `${req.agentName} (${req.color})` + const detail = req.description.length > 400 ? req.description.slice(0, 400) + "…" : req.description + + const result = await vscode.window.showInformationMessage( + `Worker approval request from ${label}`, + { modal: true, detail: `Tool: ${req.toolName}\n\n${detail}` }, + "Allow", + "Deny", + ) + return result === "Allow" +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 78d58c8f11..2a484d29a4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -80,6 +80,7 @@ import { SkillsManager } from "../../services/skills/SkillsManager" import { TeamsManager } from "../../services/teams/TeamsManager" import { SwarmRegistry } from "../swarm/SwarmRegistry" import { MailboxManager } from "../swarm/MailboxManager" +import { registerPermissionHandler, showWorkerPermissionDialog } from "../swarm/LeaderPermissionBridge" import { fileExistsAtPath } from "../../utils/fs" import { setTtsEnabled, setTtsSpeed } from "../../utils/tts" @@ -158,6 +159,7 @@ export class ClineProvider protected teamsManager?: TeamsManager protected swarmRegistry: SwarmRegistry = new SwarmRegistry() protected mailboxManager: MailboxManager = new MailboxManager() + private permissionBridgeCleanup?: () => void private marketplaceManager: MarketplaceManager private mdmService?: MdmService private taskCreationCallback: (task: Task) => void @@ -253,6 +255,9 @@ export class ClineProvider this.log(`Failed to initialize Teams Manager: ${error}`) }) + // Register as the in-process permission approval surface for concurrent workers. + this.permissionBridgeCleanup = registerPermissionHandler(showWorkerPermissionDialog) + this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) // Forward task events to the provider. @@ -747,6 +752,8 @@ export class ClineProvider this.teamsManager = undefined this.swarmRegistry.dispose() this.mailboxManager.dispose() + this.permissionBridgeCleanup?.() + this.permissionBridgeCleanup = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() this.taskHistoryStore.dispose()