feat(swarm-p4): leader permission bridge for concurrent worker tool approval

Workers that are running concurrently (parent still alive in task Map)
now route tool-approval through LeaderPermissionBridge instead of
surfacing a UI prompt on the hidden worker task.

- LeaderPermissionBridge: module-level singleton, register/submit/dialog
- presentAssistantMessage: intercept askApproval for concurrent workers
- ClineProvider: register showWorkerPermissionDialog on construction
- events/task.ts: PermissionRequested + PermissionResolved event types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
davincidreams 2026-04-25 07:26:45 -04:00
parent 4705627fe4
commit a49d9614b0
5 changed files with 149 additions and 1 deletions

View file

@ -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({

View file

@ -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]
}
/**

View file

@ -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,

View file

@ -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<boolean>
// 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<boolean> {
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<boolean> {
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"
}

View file

@ -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 <most> 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()