diff --git a/src/main/index.ts b/src/main/index.ts index ade86e3c..f944a845 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -847,6 +847,8 @@ function initializeServices(): void { } }); + teamProvisioningService.setMainWindow(mainWindow); + // startProcessHealthPolling() is deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts index 084e816c..28571967 100644 --- a/src/main/ipc/configValidation.ts +++ b/src/main/ipc/configValidation.ts @@ -118,6 +118,7 @@ function validateNotificationsSection( 'notifyOnAllTasksCompleted', 'notifyOnCrossTeamMessage', 'notifyOnTeamLaunched', + 'notifyOnToolApproval', 'statusChangeOnlySolo', 'statusChangeStatuses', 'triggers', @@ -206,6 +207,12 @@ function validateNotificationsSection( } result.notifyOnTeamLaunched = value; break; + case 'notifyOnToolApproval': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.notifyOnToolApproval = value; + break; case 'statusChangeOnlySolo': if (typeof value !== 'boolean') { return { valid: false, error: `notifications.${key} must be a boolean` }; diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts index 79999f95..07894773 100644 --- a/src/main/services/infrastructure/ConfigManager.ts +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -58,6 +58,8 @@ export interface NotificationConfig { notifyOnCrossTeamMessage: boolean; /** Whether to show native OS notifications when a team finishes launching */ notifyOnTeamLaunched: boolean; + /** Whether to show native OS notifications when a tool needs user approval */ + notifyOnToolApproval: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */ @@ -276,6 +278,7 @@ const DEFAULT_CONFIG: AppConfig = { notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, + notifyOnToolApproval: true, statusChangeOnlySolo: false, statusChangeStatuses: ['in_progress', 'completed'], triggers: DEFAULT_TRIGGERS, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index e8f2fc50..25fcd46e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,5 +1,6 @@ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; +import { getAppIconPath } from '@main/utils/appIcon'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { @@ -594,6 +595,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, `- Approve review: review_approve { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true }`, + ` Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it twice (once to approve, once with a note). The tool auto-creates a comment from the note.`, `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, ``, @@ -874,6 +876,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - Review guidance: - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X. - CRITICAL: Text comments ("approved", "LGTM") do NOT move the task on the kanban board. You MUST call the MCP tool review_approve to move from REVIEW to APPROVED. Without the tool call, the task stays stuck in REVIEW. + - Call review_approve EXACTLY ONCE per review. Include your review feedback in the "note" field of that single call. Do NOT call it a second time to add a note — one call does everything (moves kanban + creates comment from note). - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - Use related to connect it to #X (non-blocking link). - If the review truly cannot start until #X is done, ALSO add blockedBy #X. @@ -1947,11 +1950,17 @@ export class TeamProvisioningService { } private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null; + private mainWindowRef: import('electron').BrowserWindow | null = null; + private activeApprovalNotifications = new Set(); setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void { this.toolApprovalEventEmitter = emitter; } + setMainWindow(win: import('electron').BrowserWindow | null): void { + this.mainWindowRef = win; + } + updateToolApprovalSettings(settings: ToolApprovalSettings): void { this.toolApprovalSettings = settings; this.reEvaluatePendingApprovals(); @@ -5343,6 +5352,102 @@ export class TeamProvisioningService { run.pendingApprovals.set(requestId, approval); this.emitToolApprovalEvent(approval); this.startApprovalTimeout(run, requestId); + + // Show OS notification when window is not focused + 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 + * directly from the notification without switching to the app. + */ + private maybeShowToolApprovalOsNotification( + run: ProvisioningRun, + approval: ToolApprovalRequest + ): void { + const win = this.mainWindowRef; + if (win && !win.isDestroyed() && win.isFocused()) return; + + const config = ConfigManager.getInstance().getConfig(); + if (!config.notifications.enabled || !config.notifications.notifyOnToolApproval) return; + + const { Notification: ElectronNotification } = require('electron') as typeof import('electron'); + if (!ElectronNotification.isSupported()) return; + + const isMac = process.platform === 'darwin'; + const iconPath = isMac ? undefined : getAppIconPath(); + const teamLabel = run.request.displayName ?? run.teamName; + const body = this.formatToolApprovalBody(approval.toolName, approval.toolInput); + + const notification = new ElectronNotification({ + title: `Tool Approval — ${teamLabel}`, + body, + sound: config.notifications.soundEnabled ? 'default' : undefined, + ...(iconPath ? { icon: iconPath } : {}), + ...(isMac + ? { + actions: [ + { type: 'button' as const, text: 'Allow' }, + { type: 'button' as const, text: 'Deny' }, + ], + } + : {}), + }); + + // Prevent GC from collecting the notification (macOS issue) + this.activeApprovalNotifications.add(notification); + const cleanup = (): void => { + this.activeApprovalNotifications.delete(notification); + }; + + notification.on('click', () => { + cleanup(); + if (win && !win.isDestroyed()) { + win.show(); + win.focus(); + } + }); + + notification.on('close', cleanup); + + // macOS action buttons: Allow (index 0) / Deny (index 1) + if (isMac) { + notification.on('action', (_event, index) => { + cleanup(); + const allow = index === 0; + logger.info( + `[${run.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification` + ); + void this.respondToToolApproval( + run.teamName, + run.runId, + approval.requestId, + allow, + allow ? undefined : 'Denied via notification' + ).catch((err) => { + logger.error( + `[${run.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}` + ); + }); + }); + } + + notification.show(); + } + + private formatToolApprovalBody(toolName: string, toolInput: Record): string { + switch (toolName) { + case 'Bash': + return `Bash: ${typeof toolInput.command === 'string' ? toolInput.command.slice(0, 150) : 'command'}`; + case 'Write': + case 'Edit': + case 'Read': + case 'NotebookEdit': + return `${toolName}: ${typeof toolInput.file_path === 'string' ? toolInput.file_path : 'file'}`; + default: + return `${toolName}: ${JSON.stringify(toolInput).slice(0, 150)}`; + } } /** diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts index 47bd4bdd..9d9585b5 100644 --- a/src/renderer/components/settings/hooks/useSettingsConfig.ts +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -51,6 +51,7 @@ export interface SafeConfig { notifyOnAllTasksCompleted: boolean; notifyOnCrossTeamMessage: boolean; notifyOnTeamLaunched: boolean; + notifyOnToolApproval: boolean; statusChangeOnlySolo: boolean; statusChangeStatuses: string[]; triggers: AppConfig['notifications']['triggers']; @@ -189,6 +190,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn { notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true, notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true, notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true, + notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true, statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true, statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [ 'in_progress', diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts index 8e797cb2..50de430d 100644 --- a/src/renderer/components/settings/hooks/useSettingsHandlers.ts +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -304,6 +304,7 @@ export function useSettingsHandlers({ notifyOnAllTasksCompleted: true, notifyOnCrossTeamMessage: true, notifyOnTeamLaunched: true, + notifyOnToolApproval: true, statusChangeOnlySolo: true, statusChangeStatuses: ['in_progress', 'completed'], triggers: defaultTriggers, diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx index 94341322..fb4babd5 100644 --- a/src/renderer/components/settings/sections/NotificationsSection.tsx +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -28,6 +28,7 @@ import { PartyPopper, Rocket, Send, + ShieldQuestion, Users, Volume2, } from 'lucide-react'; @@ -74,6 +75,7 @@ interface NotificationsSectionProps { | 'notifyOnAllTasksCompleted' | 'notifyOnCrossTeamMessage' | 'notifyOnTeamLaunched' + | 'notifyOnToolApproval' | 'statusChangeOnlySolo', value: boolean ) => void; @@ -347,6 +349,17 @@ export const NotificationsSection = ({ disabled={saving || !safeConfig.notifications.enabled} /> + } + > + onNotificationToggle('notifyOnToolApproval', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + {/* Task Status Change Notifications — nested within team card */}
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts index 66bdb2a0..5e963300 100644 --- a/src/shared/types/notifications.ts +++ b/src/shared/types/notifications.ts @@ -289,6 +289,8 @@ export interface AppConfig { notifyOnCrossTeamMessage: boolean; /** Whether to show native OS notifications when a team finishes launching */ notifyOnTeamLaunched: boolean; + /** Whether to show native OS notifications when a tool needs user approval (Allow/Deny) */ + notifyOnToolApproval: boolean; /** Only notify on status changes in solo teams (no teammates) */ statusChangeOnlySolo: boolean; /** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */