feat: OS notification for tool approvals with Allow/Deny actions

When a tool needs approval and the app is not focused, show a native
OS notification with clear description (tool name + file/command).

On macOS: notification includes Allow and Deny action buttons that
respond directly without switching to the app.
On all platforms: clicking the notification focuses the app window.

New setting: notifyOnToolApproval (default: true) in notification
settings with ShieldQuestion icon toggle.
This commit is contained in:
iliya 2026-03-21 13:27:39 +02:00
parent e2ca78d1b2
commit 987ad96f4a
8 changed files with 135 additions and 0 deletions

View file

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

View file

@ -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` };

View file

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

View file

@ -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: "<id>", from: "${leadName}", reviewer: "<reviewer-name>" }`,
`- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "<id>", from: "<reviewer-name>" }`,
`- Approve review: review_approve { teamName: "${teamName}", taskId: "<id>", note?: "<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: "<id>", comment: "<what to fix>" }`,
`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<import('electron').Notification>();
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, unknown>): 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)}`;
}
}
/**

View file

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

View file

@ -304,6 +304,7 @@ export function useSettingsHandlers({
notifyOnAllTasksCompleted: true,
notifyOnCrossTeamMessage: true,
notifyOnTeamLaunched: true,
notifyOnToolApproval: true,
statusChangeOnlySolo: true,
statusChangeStatuses: ['in_progress', 'completed'],
triggers: defaultTriggers,

View file

@ -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}
/>
</SettingRow>
<SettingRow
label="Tool approval notifications"
description="Notify when a tool needs your approval (Allow/Deny) while the app is not focused"
icon={<ShieldQuestion className="size-4" />}
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnToolApproval}
onChange={(v) => onNotificationToggle('notifyOnToolApproval', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
{/* Task Status Change Notifications — nested within team card */}
<div className="last:*:border-b-0">

View file

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