diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts index d1c3f89c2b89..708366eee64f 100644 --- a/packages/cloud/src/CloudAPI.ts +++ b/packages/cloud/src/CloudAPI.ts @@ -1,6 +1,12 @@ import { z } from "zod" -import { type AuthService, type ShareVisibility, type ShareResponse, shareResponseSchema } from "@roo-code/types" +import { + type AuthService, + type ShareVisibility, + type ShareResponse, + shareResponseSchema, + type NotificationEvent, +} from "@roo-code/types" import { getRooCodeApiUrl } from "./config.js" import { getUserAgent } from "./utils.js" @@ -134,4 +140,29 @@ export class CloudAPI { .parse(data), }) } + + async notifyEvent(notification: NotificationEvent): Promise<{ success: boolean; error?: string }> { + this.log(`[CloudAPI] Sending notification for task ${notification.taskId}`) + + try { + const response = await this.request("/api/extension/notifications", { + method: "POST", + body: JSON.stringify(notification), + parseResponse: (data) => + z + .object({ + success: z.boolean(), + error: z.string().optional(), + }) + .parse(data), + }) + + this.log("[CloudAPI] Notification response:", response) + return response + } catch (error) { + this.log("[CloudAPI] Failed to send notification:", error) + // Return failure but don't throw - notifications are non-critical + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + } } diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index b321545ea2d9..566944da714a 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -15,6 +15,7 @@ import type { UserSettingsConfig, UserSettingsData, UserFeatures, + NotificationEvent, } from "@roo-code/types" import { TaskNotFoundError } from "./errors.js" @@ -336,6 +337,43 @@ export class CloudService extends EventEmitter implements Di return this.shareService!.canShareTask() } + // Notifications + + public async sendNotification(notification: NotificationEvent): Promise<{ success: boolean; error?: string }> { + this.ensureInitialized() + + if (!this.cloudAPI) { + return { success: false, error: "CloudAPI not initialized" } + } + + // Check if notifications are enabled for this type + const userSettings = this.getUserSettings() + const notificationSettings = userSettings?.settings?.notificationSettings + + if (notificationSettings) { + // Check specific notification type settings + switch (notification.type) { + case "task_completion": + if (notificationSettings.taskCompletion === false) { + return { success: false, error: "Task completion notifications disabled" } + } + break + case "approval_required": + if (notificationSettings.approvalRequired === false) { + return { success: false, error: "Approval required notifications disabled" } + } + break + case "followup_question": + if (notificationSettings.followUpQuestions === false) { + return { success: false, error: "Follow-up question notifications disabled" } + } + break + } + } + + return this.cloudAPI.notifyEvent(notification) + } + // Lifecycle public dispose(): void { diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index b412eb18891f..cd5f1cbd1a40 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -174,6 +174,13 @@ export type UserFeatures = z.infer export const userSettingsConfigSchema = z.object({ extensionBridgeEnabled: z.boolean().optional(), taskSyncEnabled: z.boolean().optional(), + notificationSettings: z + .object({ + taskCompletion: z.boolean().optional(), + approvalRequired: z.boolean().optional(), + followUpQuestions: z.boolean().optional(), + }) + .optional(), }) export type UserSettingsConfig = z.infer @@ -598,6 +605,41 @@ export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ export type ExtensionBridgeCommand = z.infer +/** + * NotificationType + */ + +export enum NotificationType { + TaskCompletion = "task_completion", + ApprovalRequired = "approval_required", + FollowUpQuestion = "followup_question", +} + +/** + * NotificationEvent + */ + +export interface NotificationEvent { + type: NotificationType + taskId: string + userId?: string + organizationId?: string + title: string + message: string + timestamp: number + metadata?: { + question?: string + toolName?: string + mode?: string + result?: string + askType?: string + content?: string + isProtected?: boolean + tokenUsage?: Record + suggestions?: Array<{ answer: string; mode?: string }> + } +} + /** * TaskBridgeEvent */ @@ -606,6 +648,7 @@ export enum TaskBridgeEventName { Message = RooCodeEventName.Message, TaskModeSwitched = RooCodeEventName.TaskModeSwitched, TaskInteractive = RooCodeEventName.TaskInteractive, + NotificationRequested = "notification_requested", } export const taskBridgeEventSchema = z.discriminatedUnion("type", [ @@ -624,6 +667,26 @@ export const taskBridgeEventSchema = z.discriminatedUnion("type", [ type: z.literal(TaskBridgeEventName.TaskInteractive), taskId: z.string(), }), + z.object({ + type: z.literal(TaskBridgeEventName.NotificationRequested), + taskId: z.string(), + notification: z.object({ + type: z.nativeEnum(NotificationType), + taskId: z.string(), + userId: z.string().optional(), + organizationId: z.string().optional(), + title: z.string(), + message: z.string(), + timestamp: z.number(), + metadata: z + .object({ + question: z.string().optional(), + toolName: z.string().optional(), + mode: z.string().optional(), + }) + .optional(), + }), + }), ]) export type TaskBridgeEvent = z.infer diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 8beaf1235e8c..2b08c0fc9bcc 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -35,6 +35,7 @@ import { isInteractiveAsk, isResumableAsk, QueuedMessage, + NotificationType, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" @@ -796,6 +797,77 @@ export class Task extends EventEmitter implements TaskLike { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } + // Send notification for approval requests (except followup which is handled separately) + if ( + !partial && + CloudService.isEnabled() && + (type === "tool" || type === "command" || type === "browser_action_launch" || type === "use_mcp_server") + ) { + try { + const cloudService = CloudService.instance + if (cloudService) { + const taskMode = await this.getTaskMode() + + // Determine notification type and message based on ask type + let notificationType: NotificationType = NotificationType.ApprovalRequired + let notificationMessage = "Roo needs your approval" + + if (type === "command") { + notificationMessage = "Roo wants to execute a command" + } else if (type === "tool") { + notificationMessage = "Roo wants to use a tool" + } else if (type === "browser_action_launch") { + notificationMessage = "Roo wants to launch a browser action" + } else if (type === "use_mcp_server") { + notificationMessage = "Roo wants to use an MCP server" + } + + // Extract tool/command details from the text if available + if (text) { + try { + const parsed = JSON.parse(text) + if (parsed.tool) { + notificationMessage = `Roo wants to use: ${parsed.tool}` + } else if (type === "command" && text) { + // For commands, the text is usually the command itself + const commandPreview = text.length > 50 ? text.substring(0, 50) + "..." : text + notificationMessage = `Roo wants to run: ${commandPreview}` + } + } catch { + // If not JSON, use the text directly for commands + if (type === "command" && text) { + const commandPreview = text.length > 50 ? text.substring(0, 50) + "..." : text + notificationMessage = `Roo wants to run: ${commandPreview}` + } + } + } + + // Send notification event + await cloudService + .sendNotification({ + timestamp: Date.now(), + type: notificationType, + title: "Approval Required", + message: notificationMessage, + taskId: this.taskId, + metadata: { + askType: type, + content: text, + mode: taskMode, + isProtected, + }, + }) + .catch((error) => { + // Log error but don't fail the ask operation + console.error("Failed to send approval notification:", error) + }) + } + } catch (error) { + // Don't let notification errors block the ask operation + console.error("Error sending approval notification:", error) + } + } + // The state is mutable if the message is complete and the task will // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/askFollowupQuestionTool.ts index e7369368873a..19c27104d306 100644 --- a/src/core/tools/askFollowupQuestionTool.ts +++ b/src/core/tools/askFollowupQuestionTool.ts @@ -2,6 +2,8 @@ import { Task } from "../task/Task" import { ToolUse, AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "../../shared/tools" import { formatResponse } from "../prompts/responses" import { parseXml } from "../../utils/xml" +import { NotificationType } from "@roo-code/types" +import { CloudService } from "@roo-code/cloud" export async function askFollowupQuestionTool( cline: Task, @@ -76,6 +78,38 @@ export async function askFollowupQuestionTool( } cline.consecutiveMistakeCount = 0 + + // Trigger push notification if enabled + try { + if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) { + const userInfo = CloudService.instance.getUserInfo() + const taskMode = await cline.getTaskMode() + + // Send notification event + await CloudService.instance + .sendNotification({ + type: NotificationType.FollowUpQuestion, + taskId: cline.taskId, + userId: userInfo?.id, + organizationId: userInfo?.organizationId, + title: "Roo needs your input", + message: question, + timestamp: Date.now(), + metadata: { + question, + mode: taskMode, + }, + }) + .catch((error) => { + // Log error but don't fail the tool + console.error("Failed to send notification:", error) + }) + } + } catch (error) { + // Don't fail the tool if notification fails + console.error("Error checking notification settings:", error) + } + const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false) await cline.say("user_feedback", text ?? "", images) pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index 5074d7f4e808..f8dcf6e9d05e 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -1,8 +1,9 @@ import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" -import { RooCodeEventName } from "@roo-code/types" +import { RooCodeEventName, NotificationType } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { CloudService } from "@roo-code/cloud" import { Task } from "../task/Task" import { @@ -72,6 +73,34 @@ export async function attemptCompletionTool( TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + // Send task completion notification + if (CloudService.isEnabled()) { + try { + const cloudService = CloudService.instance + if (cloudService) { + const taskMode = await cline.getTaskMode() + await cloudService + .sendNotification({ + timestamp: Date.now(), + type: NotificationType.TaskCompletion, + title: "Task Completed", + message: "Task completed successfully", + taskId: cline.taskId, + metadata: { + result: result?.substring(0, 200), // Truncate long results + mode: taskMode, + tokenUsage: cline.getTokenUsage(), + }, + }) + .catch((error) => { + console.error("Failed to send task completion notification:", error) + }) + } + } catch (error) { + console.error("Error sending task completion notification:", error) + } + } + await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) } } else { @@ -95,6 +124,34 @@ export async function attemptCompletionTool( TelemetryService.instance.captureTaskCompleted(cline.taskId) cline.emit(RooCodeEventName.TaskCompleted, cline.taskId, cline.getTokenUsage(), cline.toolUsage) + // Send task completion notification + if (CloudService.isEnabled()) { + try { + const cloudService = CloudService.instance + if (cloudService) { + const taskMode = await cline.getTaskMode() + await cloudService + .sendNotification({ + timestamp: Date.now(), + type: NotificationType.TaskCompletion, + title: "Task Completed", + message: "Task completed successfully", + taskId: cline.taskId, + metadata: { + result: result.substring(0, 200), // Truncate long results + mode: taskMode, + tokenUsage: cline.getTokenUsage(), + }, + }) + .catch((error) => { + console.error("Failed to send task completion notification:", error) + }) + } + } catch (error) { + console.error("Error sending task completion notification:", error) + } + } + if (cline.parentTask) { const didApprove = await askFinishSubTaskApproval()