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:
parent
e2ca78d1b2
commit
987ad96f4a
8 changed files with 135 additions and 0 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -304,6 +304,7 @@ export function useSettingsHandlers({
|
|||
notifyOnAllTasksCompleted: true,
|
||||
notifyOnCrossTeamMessage: true,
|
||||
notifyOnTeamLaunched: true,
|
||||
notifyOnToolApproval: true,
|
||||
statusChangeOnlySolo: true,
|
||||
statusChangeStatuses: ['in_progress', 'completed'],
|
||||
triggers: defaultTriggers,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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']) */
|
||||
|
|
|
|||
Loading…
Reference in a new issue