diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f5ef1501..dfcee64f 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -32,7 +32,11 @@ import { getMemberColorByName } from '@shared/constants/memberColors'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; -import { isInboxNoiseMessage } from '@shared/utils/inboxNoise'; +import { + isInboxNoiseMessage, + parsePermissionRequest, + type ParsedPermissionRequest, +} from '@shared/utils/inboxNoise'; import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; @@ -4027,12 +4031,35 @@ export class TeamProvisioningService { ); const deferredIds = new Set(deferredByAge.map((m) => m.messageId)); + // Category 4: teammate permission requests — intercept and convert to tool approvals. + // Don't relay these to the lead agent (it can't handle them). + const permissionRequestMsgs = unread.filter( + (m) => + !permanentlyIgnoredIds.has(m.messageId) && + !nativeMatchedMessageIds.has(m.messageId) && + !deferredIds.has(m.messageId) && + parsePermissionRequest(m.text) !== null + ); + const permissionRequestIds = new Set(permissionRequestMsgs.map((m) => m.messageId)); + if (permissionRequestMsgs.length > 0) { + for (const msg of permissionRequestMsgs) { + const perm = parsePermissionRequest(msg.text)!; + this.handleTeammatePermissionRequest(run, perm, msg.timestamp); + } + try { + await this.markInboxMessagesRead(teamName, leadName, permissionRequestMsgs); + } catch { + // best-effort + } + } + // Actionable: everything not in any category. const actionableUnread = unread.filter( (m) => !permanentlyIgnoredIds.has(m.messageId) && !nativeMatchedMessageIds.has(m.messageId) && - !deferredIds.has(m.messageId) + !deferredIds.has(m.messageId) && + !permissionRequestIds.has(m.messageId) ); // Layer 3: schedule retry timers. @@ -5536,6 +5563,52 @@ export class TeamProvisioningService { this.maybeShowToolApprovalOsNotification(run, approval); } + /** + * Handles a teammate permission_request received via inbox message. + * Converts it to a ToolApprovalRequest and feeds it into the existing approval flow. + */ + private handleTeammatePermissionRequest( + run: ProvisioningRun, + perm: ParsedPermissionRequest, + messageTimestamp: string + ): void { + // Skip if already tracked (idempotency — relay can be called multiple times) + if (run.pendingApprovals.has(perm.requestId)) return; + + const approval: ToolApprovalRequest = { + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + source: perm.agentId, + toolName: perm.toolName, + toolInput: perm.input, + receivedAt: messageTimestamp || new Date().toISOString(), + teamColor: run.request.color, + teamDisplayName: run.request.displayName, + }; + + const autoResult = shouldAutoAllow(this.toolApprovalSettings, perm.toolName, perm.input); + if (autoResult.autoAllow) { + logger.info( + `[${run.teamName}] Auto-allowing teammate ${perm.agentId} ${perm.toolName} (${autoResult.reason})` + ); + void this.respondToTeammatePermission(run, perm.agentId, perm.requestId, true); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId: perm.requestId, + runId: run.runId, + teamName: run.teamName, + reason: 'auto_allow_category', + } as ToolApprovalAutoResolved); + return; + } + + run.pendingApprovals.set(perm.requestId, approval); + this.emitToolApprovalEvent(approval); + this.startApprovalTimeout(run, perm.requestId); + this.maybeShowToolApprovalOsNotification(run, approval); + } + /** * Shows a native OS notification for a pending tool approval when the app * is not in focus. On macOS, adds Allow/Deny action buttons that respond @@ -5704,6 +5777,31 @@ export class TeamProvisioningService { const allow = currentAction === 'allow'; logger.info(`[${run.teamName}] Timeout ${allow ? 'allowing' : 'denying'} ${requestId}`); + const approval = run.pendingApprovals.get(requestId); + if (approval && approval.source !== 'lead') { + // Teammate request — respond via inbox + control_response fallback. + // Defer cleanup until the async write completes to avoid silent data loss. + this.respondToTeammatePermission( + run, + approval.source, + requestId, + allow, + allow ? undefined : 'Timed out — auto-denied by settings' + ).finally(() => { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + this.emitToolApprovalEvent({ + autoResolved: true, + requestId, + runId: run.runId, + teamName: run.teamName, + reason: allow ? 'timeout_allow' : 'timeout_deny', + } as ToolApprovalAutoResolved); + }); + return; + } + if (allow) { this.autoAllowControlRequest(run, requestId); } else { @@ -5768,7 +5866,12 @@ export class TeamProvisioningService { ); if (result.autoAllow) { this.clearApprovalTimeout(requestId); - this.autoAllowControlRequest(run, requestId); + if (!this.tryClaimResponse(requestId)) continue; + if (approval.source !== 'lead') { + void this.respondToTeammatePermission(run, approval.source, requestId, true); + } else { + this.autoAllowControlRequest(run, requestId); + } this.dismissApprovalNotification(requestId); toRemove.push(requestId); this.emitToolApprovalEvent({ @@ -5794,6 +5897,7 @@ export class TeamProvisioningService { } for (const requestId of toRemove) { run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); } } } @@ -5834,6 +5938,20 @@ export class TeamProvisioningService { return; } + const approval = run.pendingApprovals.get(requestId)!; + + // Teammate permission requests use a different response path (inbox, not stdin) + if (approval.source !== 'lead') { + try { + await this.respondToTeammatePermission(run, approval.source, requestId, allow, message); + } finally { + run.pendingApprovals.delete(requestId); + this.inFlightResponses.delete(requestId); + this.dismissApprovalNotification(requestId); + } + return; + } + if (!run.child?.stdin?.writable) { throw new Error(`Team "${teamName}" process stdin is not writable`); } @@ -5889,6 +6007,93 @@ export class TeamProvisioningService { } } + /** + * Respond to a teammate's permission_request by writing to the teammate's inbox + * AND attempting a control_response via stdin (belt-and-suspenders). + */ + private async respondToTeammatePermission( + run: ProvisioningRun, + agentId: string, + requestId: string, + allow: boolean, + message?: string + ): Promise { + const teamsBase = getTeamsBasePath(); + const inboxPath = path.join(teamsBase, run.teamName, 'inboxes', `${agentId}.json`); + + // 1. Write permission_response to teammate's inbox (with proper file locking) + const responseMsg = { + from: 'user', + text: JSON.stringify({ + type: 'permission_response', + request_id: requestId, + approved: allow, + ...(message ? { message } : {}), + }), + timestamp: new Date().toISOString(), + read: false, + }; + + try { + await withFileLock(inboxPath, async () => { + await withInboxLock(inboxPath, async () => { + let existing: unknown[] = []; + try { + const raw = await tryReadRegularFileUtf8(inboxPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_INBOX_MAX_BYTES, + }); + if (raw) { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) existing = parsed; + } + } catch { + // File may not exist yet — start with empty array + } + existing.push(responseMsg); + await atomicWriteAsync(inboxPath, JSON.stringify(existing, null, 2)); + }); + }); + logger.info( + `[${run.teamName}] Wrote permission_response to ${agentId} inbox: ${allow ? 'allow' : 'deny'}` + ); + } catch (error) { + logger.error( + `[${run.teamName}] Failed to write permission_response to ${agentId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + // 2. Also try control_response via stdin (in case lead runtime can forward it) + if (run.child?.stdin?.writable) { + const controlResponse = allow + ? { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'allow' }, + }, + } + : { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { behavior: 'deny', message: message ?? 'User denied' }, + }, + }; + run.child.stdin.write(JSON.stringify(controlResponse) + '\n', (err) => { + if (err) { + logger.warn( + `[${run.teamName}] control_response via stdin for teammate ${agentId} failed (non-critical): ${err.message}` + ); + } + }); + } + } + /** * Called when the first stream-json turn completes successfully. * Verifies provisioning files exist and marks as ready. diff --git a/src/renderer/components/team/ToolApprovalSheet.tsx b/src/renderer/components/team/ToolApprovalSheet.tsx index 7e0292f3..01ae8ab1 100644 --- a/src/renderer/components/team/ToolApprovalSheet.tsx +++ b/src/renderer/components/team/ToolApprovalSheet.tsx @@ -202,6 +202,7 @@ export const ToolApprovalSheet: React.FC = () => {
{getToolIcon(current.toolName)} + {current.source !== 'lead' ? `${current.source} — ` : ''} {current.toolName}
diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 2dfc1606..a456b6cc 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -222,6 +222,19 @@ function getNoiseLabel(parsed: StructuredMessage): string | null { : 'Completed a task'; } + if (type === 'permission_request') { + const toolName = getStringField(parsed, 'tool_name'); + const description = getStringField(parsed, 'description'); + const label = toolName ? `Permission: ${toolName}` : 'Permission request'; + return description ? `${label} — ${description}` : label; + } + + if (type === 'permission_response') { + if (parsed.approved === true) return 'Permission granted'; + if (parsed.approved === false) return 'Permission denied'; + return 'Permission response'; + } + return null; } diff --git a/src/renderer/utils/agentMessageFormatting.ts b/src/renderer/utils/agentMessageFormatting.ts index f194bc96..354f1513 100644 --- a/src/renderer/utils/agentMessageFormatting.ts +++ b/src/renderer/utils/agentMessageFormatting.ts @@ -73,6 +73,8 @@ const TYPE_LABELS: Record = { shutdown_response: 'Shutdown response', message: 'Message', broadcast: 'Broadcast', + permission_request: 'Permission request', + permission_response: 'Permission response', }; export function parseStructuredAgentMessage(content: string): StructuredAgentMessage | null { diff --git a/src/shared/utils/inboxNoise.ts b/src/shared/utils/inboxNoise.ts index d390076c..5a85497c 100644 --- a/src/shared/utils/inboxNoise.ts +++ b/src/shared/utils/inboxNoise.ts @@ -37,6 +37,47 @@ export function isInboxNoiseMessage(text: string): boolean { return !!type && INBOX_NOISE_SET.has(type); } +// --------------------------------------------------------------------------- +// Teammate permission request parsing +// --------------------------------------------------------------------------- + +/** Parsed teammate permission request from inbox message. */ +export interface ParsedPermissionRequest { + requestId: string; + agentId: string; + toolName: string; + toolUseId: string; + description: string; + input: Record; +} + +/** + * Parses a `permission_request` JSON message from a teammate's inbox entry. + * Returns null if the text is not a valid permission_request. + */ +export function parsePermissionRequest(text: string): ParsedPermissionRequest | null { + const parsed = parseInboxJson(text); + if (!parsed || parsed.type !== 'permission_request') return null; + + const requestId = typeof parsed.request_id === 'string' ? parsed.request_id : null; + const agentId = typeof parsed.agent_id === 'string' ? parsed.agent_id : null; + const toolName = typeof parsed.tool_name === 'string' ? parsed.tool_name : null; + + if (!requestId || !agentId || !toolName) return null; + + return { + requestId, + agentId, + toolName, + toolUseId: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + description: typeof parsed.description === 'string' ? parsed.description : '', + input: + parsed.input && typeof parsed.input === 'object' && !Array.isArray(parsed.input) + ? (parsed.input as Record) + : {}, + }; +} + // --------------------------------------------------------------------------- // Teammate-message XML block detection & stripping // ---------------------------------------------------------------------------