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
|
// startProcessHealthPolling() is deferred to after window creation
|
||||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ function validateNotificationsSection(
|
||||||
'notifyOnAllTasksCompleted',
|
'notifyOnAllTasksCompleted',
|
||||||
'notifyOnCrossTeamMessage',
|
'notifyOnCrossTeamMessage',
|
||||||
'notifyOnTeamLaunched',
|
'notifyOnTeamLaunched',
|
||||||
|
'notifyOnToolApproval',
|
||||||
'statusChangeOnlySolo',
|
'statusChangeOnlySolo',
|
||||||
'statusChangeStatuses',
|
'statusChangeStatuses',
|
||||||
'triggers',
|
'triggers',
|
||||||
|
|
@ -206,6 +207,12 @@ function validateNotificationsSection(
|
||||||
}
|
}
|
||||||
result.notifyOnTeamLaunched = value;
|
result.notifyOnTeamLaunched = value;
|
||||||
break;
|
break;
|
||||||
|
case 'notifyOnToolApproval':
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
}
|
||||||
|
result.notifyOnToolApproval = value;
|
||||||
|
break;
|
||||||
case 'statusChangeOnlySolo':
|
case 'statusChangeOnlySolo':
|
||||||
if (typeof value !== 'boolean') {
|
if (typeof value !== 'boolean') {
|
||||||
return { valid: false, error: `notifications.${key} must be a boolean` };
|
return { valid: false, error: `notifications.${key} must be a boolean` };
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ export interface NotificationConfig {
|
||||||
notifyOnCrossTeamMessage: boolean;
|
notifyOnCrossTeamMessage: boolean;
|
||||||
/** Whether to show native OS notifications when a team finishes launching */
|
/** Whether to show native OS notifications when a team finishes launching */
|
||||||
notifyOnTeamLaunched: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
@ -276,6 +278,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
notifyOnAllTasksCompleted: true,
|
notifyOnAllTasksCompleted: true,
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
|
notifyOnToolApproval: true,
|
||||||
statusChangeOnlySolo: false,
|
statusChangeOnlySolo: false,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||||
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
|
||||||
|
import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead';
|
||||||
import {
|
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>" }`,
|
`- 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>" }`,
|
`- 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 }`,
|
`- 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>" }`,
|
`- 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.`,
|
`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:
|
- 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.
|
- 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.
|
- 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:
|
- 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).
|
- Use related to connect it to #X (non-blocking link).
|
||||||
- If the review truly cannot start until #X is done, ALSO add blockedBy #X.
|
- 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 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 {
|
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
|
||||||
this.toolApprovalEventEmitter = emitter;
|
this.toolApprovalEventEmitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMainWindow(win: import('electron').BrowserWindow | null): void {
|
||||||
|
this.mainWindowRef = win;
|
||||||
|
}
|
||||||
|
|
||||||
updateToolApprovalSettings(settings: ToolApprovalSettings): void {
|
updateToolApprovalSettings(settings: ToolApprovalSettings): void {
|
||||||
this.toolApprovalSettings = settings;
|
this.toolApprovalSettings = settings;
|
||||||
this.reEvaluatePendingApprovals();
|
this.reEvaluatePendingApprovals();
|
||||||
|
|
@ -5343,6 +5352,102 @@ export class TeamProvisioningService {
|
||||||
run.pendingApprovals.set(requestId, approval);
|
run.pendingApprovals.set(requestId, approval);
|
||||||
this.emitToolApprovalEvent(approval);
|
this.emitToolApprovalEvent(approval);
|
||||||
this.startApprovalTimeout(run, requestId);
|
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;
|
notifyOnAllTasksCompleted: boolean;
|
||||||
notifyOnCrossTeamMessage: boolean;
|
notifyOnCrossTeamMessage: boolean;
|
||||||
notifyOnTeamLaunched: boolean;
|
notifyOnTeamLaunched: boolean;
|
||||||
|
notifyOnToolApproval: boolean;
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
statusChangeStatuses: string[];
|
statusChangeStatuses: string[];
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
|
|
@ -189,6 +190,7 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
notifyOnAllTasksCompleted: displayConfig?.notifications?.notifyOnAllTasksCompleted ?? true,
|
||||||
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
notifyOnCrossTeamMessage: displayConfig?.notifications?.notifyOnCrossTeamMessage ?? true,
|
||||||
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
notifyOnTeamLaunched: displayConfig?.notifications?.notifyOnTeamLaunched ?? true,
|
||||||
|
notifyOnToolApproval: displayConfig?.notifications?.notifyOnToolApproval ?? true,
|
||||||
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
statusChangeOnlySolo: displayConfig?.notifications?.statusChangeOnlySolo ?? true,
|
||||||
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
statusChangeStatuses: displayConfig?.notifications?.statusChangeStatuses ?? [
|
||||||
'in_progress',
|
'in_progress',
|
||||||
|
|
|
||||||
|
|
@ -304,6 +304,7 @@ export function useSettingsHandlers({
|
||||||
notifyOnAllTasksCompleted: true,
|
notifyOnAllTasksCompleted: true,
|
||||||
notifyOnCrossTeamMessage: true,
|
notifyOnCrossTeamMessage: true,
|
||||||
notifyOnTeamLaunched: true,
|
notifyOnTeamLaunched: true,
|
||||||
|
notifyOnToolApproval: true,
|
||||||
statusChangeOnlySolo: true,
|
statusChangeOnlySolo: true,
|
||||||
statusChangeStatuses: ['in_progress', 'completed'],
|
statusChangeStatuses: ['in_progress', 'completed'],
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
PartyPopper,
|
PartyPopper,
|
||||||
Rocket,
|
Rocket,
|
||||||
Send,
|
Send,
|
||||||
|
ShieldQuestion,
|
||||||
Users,
|
Users,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -74,6 +75,7 @@ interface NotificationsSectionProps {
|
||||||
| 'notifyOnAllTasksCompleted'
|
| 'notifyOnAllTasksCompleted'
|
||||||
| 'notifyOnCrossTeamMessage'
|
| 'notifyOnCrossTeamMessage'
|
||||||
| 'notifyOnTeamLaunched'
|
| 'notifyOnTeamLaunched'
|
||||||
|
| 'notifyOnToolApproval'
|
||||||
| 'statusChangeOnlySolo',
|
| 'statusChangeOnlySolo',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -347,6 +349,17 @@ export const NotificationsSection = ({
|
||||||
disabled={saving || !safeConfig.notifications.enabled}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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 */}
|
{/* Task Status Change Notifications — nested within team card */}
|
||||||
<div className="last:*:border-b-0">
|
<div className="last:*:border-b-0">
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,8 @@ export interface AppConfig {
|
||||||
notifyOnCrossTeamMessage: boolean;
|
notifyOnCrossTeamMessage: boolean;
|
||||||
/** Whether to show native OS notifications when a team finishes launching */
|
/** Whether to show native OS notifications when a team finishes launching */
|
||||||
notifyOnTeamLaunched: boolean;
|
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) */
|
/** Only notify on status changes in solo teams (no teammates) */
|
||||||
statusChangeOnlySolo: boolean;
|
statusChangeOnlySolo: boolean;
|
||||||
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue