diff --git a/.oxlintrc.json b/.oxlintrc.json index e01796d6bae..bde71549044 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1279,7 +1279,6 @@ "@/node/runtime/runtimeHelpers" ], "src/node/services/tools/bash.ts": ["fs"], - "src/node/services/tools/notify.ts": ["electron"], "src/node/services/tools/review_pane.ts": [ "@/node/utils/writeFileAtomic", "fs/promises" diff --git a/eslint.config.mjs b/eslint.config.mjs index 61c89a8a211..0b97bb1ab70 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -188,7 +188,6 @@ const HOST_IMPORT_ALLOW = { "@/node/runtime/runtimeHelpers", ], "src/node/services/tools/bash.ts": ["fs"], - "src/node/services/tools/notify.ts": ["electron"], "src/node/services/tools/review_pane.ts": ["@/node/utils/writeFileAtomic", "fs/promises"], "src/node/services/tools/shared/configReadWrite.ts": [ "@/node/utils/writeFileAtomic", diff --git a/scripts/noHostImports.test.ts b/scripts/noHostImports.test.ts index 5aad47f6e93..9bec346d78a 100644 --- a/scripts/noHostImports.test.ts +++ b/scripts/noHostImports.test.ts @@ -129,7 +129,6 @@ const FROZEN_ALLOW_PAIRS = [ "src/node/services/aiService.ts fs/promises", "src/node/services/contextManagement/strategies/tokenBudget.ts @/node/runtime/runtimeHelpers", "src/node/services/tools/bash.ts fs", - "src/node/services/tools/notify.ts electron", "src/node/services/tools/review_pane.ts @/node/utils/writeFileAtomic", "src/node/services/tools/review_pane.ts fs/promises", "src/node/services/tools/shared/configReadWrite.ts @/node/utils/writeFileAtomic", diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index 31a30b69d81..c3f85c25d31 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -92,6 +92,7 @@ import * as os from "os"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; +import type { DesktopNotifications } from "@/node/services/desktopNotifications/desktopNotifications"; import type { ComputerUseService } from "@/node/services/computerUse/computerUseService"; import type { TaskService } from "@/node/services/taskService"; import type { WorkspaceTurnManager } from "@/node/services/workspaceTurnManager"; @@ -165,6 +166,8 @@ export interface ToolConfiguration { hookTurn?: HookTurn; /** Runtime environment for executing commands and file operations */ runtime: Runtime; + /** OS notifications for the notify tool. Absent: the Node layer (configs that tests build by hand). */ + desktopNotifications?: DesktopNotifications; /** Project roots in this workspace (single- or multi-project context for tool descriptions) */ projects?: ProjectRef[]; /** Environment secrets to inject (optional) */ diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index c865e95139a..7de5f7cdd15 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -27,6 +27,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { ensurePrivateDir } from "@/node/utils/fs"; import { TurnRequestBuilder, + type LoopHostServices, type TurnRequestBuilderBindings, resolveXumToolScope, type StreamMessageOptions, @@ -216,7 +217,9 @@ export class AIService extends EventEmitter { providersConfigStore?: ProvidersConfigStore, private readonly secretsStore: Pick = new SecretsStore( config.rootDir - ) + ), + /** Absent members: the Node layer (`new AIService(` in tests passes none). */ + hostServices: LoopHostServices = {} ) { super(); // No listener cap: every live AgentSession adds one listener per forwarded event, and a @@ -269,6 +272,7 @@ export class AIService extends EventEmitter { experimentsService: this.experimentsService, lastLlmRequestByWorkspace: this.lastLlmRequestByWorkspace, bindings: this.turnRequestBuilderBindings, + hostServices, emit: (event, ...args) => this.emit(event, ...args), createAbortedTurnHandle: (messageId, signal) => this.createAbortedTurnHandle(messageId, signal), diff --git a/src/node/services/desktopNotifications/desktopNotifications.ts b/src/node/services/desktopNotifications/desktopNotifications.ts new file mode 100644 index 00000000000..9b275166b90 --- /dev/null +++ b/src/node/services/desktopNotifications/desktopNotifications.ts @@ -0,0 +1,34 @@ +/** + * The OS notification that the notify tool shows. Loop code (notify.ts) reads this interface + * from ToolConfiguration instead of importing electron (local/no-host-imports). The Node layer + * is electronDesktopNotifications; tests pass a recording one + * (recordingDesktopNotifications.testHarness.ts). + */ + +/** Maximum notification body length (macOS limit is 256 bytes) */ +export const MAX_NOTIFICATION_BODY_LENGTH = 200; + +/** Maximum notification title length */ +export const MAX_NOTIFICATION_TITLE_LENGTH = 64; + +/** + * Truncates text to a maximum length, adding ellipsis if truncated + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.slice(0, maxLength - 1) + "…"; +} + +export interface DesktopNotificationRequest { + title: string; + body?: string; + /** Clicking the notification navigates to this workspace. */ + workspaceId?: string; +} + +export interface DesktopNotifications { + /** success false: no OS notification; the notify tool then falls back to the browser. */ + show(request: DesktopNotificationRequest): Promise<{ success: boolean; error?: string }>; +} diff --git a/src/node/services/desktopNotifications/electronDesktopNotifications.test.ts b/src/node/services/desktopNotifications/electronDesktopNotifications.test.ts new file mode 100644 index 00000000000..c81c7f42770 --- /dev/null +++ b/src/node/services/desktopNotifications/electronDesktopNotifications.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "bun:test"; +import { + MAX_NOTIFICATION_BODY_LENGTH, + MAX_NOTIFICATION_TITLE_LENGTH, +} from "./desktopNotifications"; +import { + createElectronDesktopNotifications, + electronDesktopNotifications, + type ElectronNotificationApi, +} from "./electronDesktopNotifications"; + +// bun test runs outside Electron: the Node layer must refuse before it imports electron, with +// the error that notify.ts returned before the move, so the tool still falls back to the browser. +test("outside Electron the Node layer shows nothing and says why", async () => { + expect(await electronDesktopNotifications.show({ title: "t", workspaceId: "ws" })).toEqual({ + success: false, + error: "System notifications not supported (not running in Electron)", + }); +}); + +interface FakeWindow { + minimized: boolean; + calls: string[]; + sent: Array<[string, { workspaceId: string }]>; +} + +/** A fake electron module that records what the layer does with it. */ +function fakeElectron(options: { supported?: boolean; windows?: FakeWindow[] } = {}) { + const shown: Array<{ title: string; body?: string; silent: boolean }> = []; + const clickListeners: Array<() => void> = []; + const windows = options.windows ?? []; + const api: ElectronNotificationApi = { + Notification: class { + static isSupported() { + return options.supported ?? true; + } + constructor(private readonly notificationOptions: (typeof shown)[number]) {} + on(_event: "click", listener: () => void) { + clickListeners.push(listener); + } + show() { + shown.push(this.notificationOptions); + } + }, + BrowserWindow: { + getAllWindows: () => + windows.map((window) => ({ + isMinimized: () => window.minimized, + restore: () => window.calls.push("restore"), + focus: () => window.calls.push("focus"), + webContents: { send: (channel, payload) => window.sent.push([channel, payload]) }, + })), + }, + }; + const notifications = createElectronDesktopNotifications({ + isElectron: () => true, + loadElectron: () => Promise.resolve(api), + }); + return { notifications, shown, click: () => clickListeners.forEach((listener) => listener()) }; +} + +function fakeWindow(minimized: boolean): FakeWindow { + return { minimized, calls: [], sent: [] }; +} + +describe("the Node layer in Electron", () => { + test("shows one notification with the title and body cut to the OS limits", async () => { + const electron = fakeElectron(); + const result = await electron.notifications.show({ + title: "t".repeat(MAX_NOTIFICATION_TITLE_LENGTH + 5), + body: "b".repeat(MAX_NOTIFICATION_BODY_LENGTH + 5), + }); + expect(result).toEqual({ success: true }); + expect(electron.shown).toEqual([ + { + title: `${"t".repeat(MAX_NOTIFICATION_TITLE_LENGTH - 1)}…`, + body: `${"b".repeat(MAX_NOTIFICATION_BODY_LENGTH - 1)}…`, + silent: false, + }, + ]); + }); + + test("shows nothing on a platform without notifications", async () => { + const electron = fakeElectron({ supported: false }); + expect(await electron.notifications.show({ title: "t" })).toEqual({ + success: false, + error: "System notifications not supported on this platform", + }); + expect(electron.shown).toEqual([]); + }); + + test("reports a failed electron import as an error", async () => { + const notifications = createElectronDesktopNotifications({ + isElectron: () => true, + loadElectron: () => Promise.reject(new Error("no electron")), + }); + expect(await notifications.show({ title: "t" })).toEqual({ + success: false, + error: "Failed to send notification: no electron", + }); + }); + + test("a click restores and focuses the first window, then opens the workspace", async () => { + const first = fakeWindow(true); + const second = fakeWindow(false); + const electron = fakeElectron({ windows: [first, second] }); + await electron.notifications.show({ title: "t", workspaceId: "ws-1" }); + electron.click(); + expect(first.calls).toEqual(["restore", "focus"]); + expect(first.sent).toEqual([["mux:notification-clicked", { workspaceId: "ws-1" }]]); + expect(second.calls).toEqual([]); + }); + + test("a click without a workspace only focuses, and a click with no window does nothing", async () => { + const window = fakeWindow(false); + const electron = fakeElectron({ windows: [window] }); + await electron.notifications.show({ title: "t" }); + electron.click(); + expect(window.calls).toEqual(["focus"]); + expect(window.sent).toEqual([]); + + const noWindow = fakeElectron(); + await noWindow.notifications.show({ title: "t", workspaceId: "ws-1" }); + expect(() => noWindow.click()).not.toThrow(); + }); +}); diff --git a/src/node/services/desktopNotifications/electronDesktopNotifications.ts b/src/node/services/desktopNotifications/electronDesktopNotifications.ts new file mode 100644 index 00000000000..37bbac4f3ae --- /dev/null +++ b/src/node/services/desktopNotifications/electronDesktopNotifications.ts @@ -0,0 +1,122 @@ +/** + * The Node layer of DesktopNotifications: Electron's Notification API, moved here from + * tools/notify.ts so that loop code does not import electron (local/no-host-imports). + * This file is not an agent-loop file. + */ + +import { getErrorMessage } from "@/common/utils/errors"; +import { + MAX_NOTIFICATION_BODY_LENGTH, + MAX_NOTIFICATION_TITLE_LENGTH, + truncateText, + type DesktopNotifications, +} from "./desktopNotifications"; + +/** The parts of the electron module that this layer uses. Tests pass a fake one. */ +export interface ElectronNotificationApi { + Notification: { + isSupported(): boolean; + new (options: { title: string; body?: string; silent: boolean }): { + on(event: "click", listener: () => void): unknown; + show(): void; + }; + }; + BrowserWindow: { + getAllWindows(): Array<{ + isMinimized(): boolean; + restore(): void; + focus(): void; + webContents: { send(channel: string, payload: { workspaceId: string }): void }; + }>; + }; +} + +export interface ElectronHost { + /** True when this process runs in Electron. */ + isElectron(): boolean; + /** Imports electron. Called only when isElectron() is true. */ + loadElectron(): Promise; +} + +const realElectronHost: ElectronHost = { + isElectron: () => typeof process.versions.electron === "string", + loadElectron: async () => { + // eslint-disable-next-line no-restricted-syntax -- Electron is unavailable in `mux server`; avoid top-level import + const { Notification, BrowserWindow } = await import("electron"); + return { Notification, BrowserWindow }; + }, +}; + +/** + * Send a system notification using Electron's Notification API. + * When clicked, focuses the app and navigates to the specified workspace. + * Returns true if notification was shown, false otherwise. + */ +async function sendElectronNotification( + host: ElectronHost, + title: string, + body?: string, + workspaceId?: string +): Promise<{ success: boolean; error?: string }> { + if (!host.isElectron()) { + return { + success: false, + error: "System notifications not supported (not running in Electron)", + }; + } + + try { + const { Notification, BrowserWindow } = await host.loadElectron(); + + if (!Notification.isSupported()) { + return { + success: false, + error: "System notifications not supported on this platform", + }; + } + + const notification = new Notification({ + title: truncateText(title, MAX_NOTIFICATION_TITLE_LENGTH), + body: body ? truncateText(body, MAX_NOTIFICATION_BODY_LENGTH) : undefined, + silent: false, + }); + + // Handle notification click - focus app and navigate to workspace + notification.on("click", () => { + const windows = BrowserWindow.getAllWindows(); + const mainWindow = windows[0]; + if (mainWindow) { + // Restore if minimized, then focus + if (mainWindow.isMinimized()) { + mainWindow.restore(); + } + mainWindow.focus(); + + // Send IPC message to renderer to navigate to workspace + if (workspaceId) { + mainWindow.webContents.send("mux:notification-clicked", { workspaceId }); + } + } + }); + + notification.show(); + + return { success: true }; + } catch (error) { + const message = getErrorMessage(error); + return { + success: false, + error: `Failed to send notification: ${message}`, + }; + } +} + +/** The Node layer on a given electron host. Tests pass a fake host. */ +export function createElectronDesktopNotifications(host: ElectronHost): DesktopNotifications { + return { + show: (request) => + sendElectronNotification(host, request.title, request.body, request.workspaceId), + }; +} + +export const electronDesktopNotifications = createElectronDesktopNotifications(realElectronHost); diff --git a/src/node/services/desktopNotifications/recordingDesktopNotifications.testHarness.ts b/src/node/services/desktopNotifications/recordingDesktopNotifications.testHarness.ts new file mode 100644 index 00000000000..d8d16d1c2c5 --- /dev/null +++ b/src/node/services/desktopNotifications/recordingDesktopNotifications.testHarness.ts @@ -0,0 +1,20 @@ +import type { DesktopNotificationRequest, DesktopNotifications } from "./desktopNotifications"; + +/** The test layer of DesktopNotifications: it shows nothing and records each request. */ +export interface RecordingDesktopNotifications extends DesktopNotifications { + readonly requests: DesktopNotificationRequest[]; +} + +/** result: what show() answers, as an OS notification would (default: shown). */ +export function createRecordingDesktopNotifications( + result: { success: boolean; error?: string } = { success: true } +): RecordingDesktopNotifications { + const requests: DesktopNotificationRequest[] = []; + return { + requests, + show: (request) => { + requests.push(request); + return Promise.resolve(result); + }, + }; +} diff --git a/src/node/services/di/layers/core.ts b/src/node/services/di/layers/core.ts index 7a9f62f87ac..d5c18e3da14 100644 --- a/src/node/services/di/layers/core.ts +++ b/src/node/services/di/layers/core.ts @@ -33,6 +33,7 @@ import { BackgroundProcessManagerTag, ConfigTag, ContextManagement, + DesktopNotificationsTag, Evaluation, ExtensionMetadata, FileLeaseManagerTag, @@ -336,7 +337,8 @@ export const AILive = Layer.effect( yield* StreamManagerTag, yield* TurnRequestBuilderBindingsTag, yield* ProvidersConfigStoreTag, - yield* SecretsStoreTag + yield* SecretsStoreTag, + { desktopNotifications: yield* DesktopNotificationsTag } ); }) ); diff --git a/src/node/services/di/layers/stores.ts b/src/node/services/di/layers/stores.ts index 8c072b9ece3..753402cd329 100644 --- a/src/node/services/di/layers/stores.ts +++ b/src/node/services/di/layers/stores.ts @@ -7,9 +7,11 @@ import { type ConfigStores, } from "@/node/config"; import type { CoreServicesOptions } from "@/node/services/coreServices"; +import { electronDesktopNotifications } from "@/node/services/desktopNotifications/electronDesktopNotifications"; import { SessionLocksNode } from "@/node/services/sessionLocks/nodeSessionLocks"; import { ConfigTag, + DesktopNotificationsTag, FileLeaseManagerTag, ProvidersConfigStoreTag, SecretsStoreTag, @@ -37,7 +39,8 @@ export function StoresLive(stores: ConfigStores): Layer.Layer { SessionLocksNode, Layer.succeed(ProvidersConfigStoreTag)(stores.providersConfigStore), Layer.succeed(SecretsStoreTag)(stores.secretsStore), - Layer.succeed(FileLeaseManagerTag)(stores.fileLeaseManager) + Layer.succeed(FileLeaseManagerTag)(stores.fileLeaseManager), + Layer.succeed(DesktopNotificationsTag)(electronDesktopNotifications) ); } diff --git a/src/node/services/di/tags.ts b/src/node/services/di/tags.ts index 8f770c99705..3381d2187f3 100644 --- a/src/node/services/di/tags.ts +++ b/src/node/services/di/tags.ts @@ -27,6 +27,7 @@ import type { AutoModelRouter } from "@/node/services/autoModelRouter"; import type { AnalyticsService } from "@/node/services/analytics/analyticsService"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { BackupService } from "@/node/services/backup/backupService"; +import type { DesktopNotifications } from "@/node/services/desktopNotifications/desktopNotifications"; import type { AgentBrowserSessionDiscoveryService } from "@/node/services/browser/AgentBrowserSessionDiscoveryService"; import type { BrowserBridgeServer } from "@/node/services/browser/BrowserBridgeServer"; import type { BrowserBridgeTokenManager } from "@/node/services/browser/BrowserBridgeTokenManager"; @@ -121,6 +122,11 @@ export class ProvidersConfigStoreTag extends Context.Service< export class SecretsStoreTag extends Context.Service()( "xum/SecretsStore" ) {} +/** OS notifications of the notify tool (desktopNotifications/). */ +export class DesktopNotificationsTag extends Context.Service< + DesktopNotificationsTag, + DesktopNotifications +>()("xum/DesktopNotifications") {} export class FileLeaseManagerTag extends Context.Service()( "xum/FileLeaseManager" ) {} @@ -324,7 +330,10 @@ export class BashAiProxy extends Context.Service { let config: ToolConfiguration; @@ -120,4 +121,41 @@ describe("notify tool", () => { expect(result.ui_only?.notify?.workspaceId).toBe("test-workspace-123"); } }); + + it("shows the notification through config.desktopNotifications", async () => { + const notifications = createRecordingDesktopNotifications(); + const tool = createNotifyTool({ + ...config, + workspaceId: "ws-1", + desktopNotifications: notifications, + }); + const execute = tool.execute as (args: { + title: string; + message?: string; + }) => Promise; + + const result = await execute({ title: ` ${"t".repeat(70)} `, message: " body " }); + + const title = `${"t".repeat(63)}…`; + expect(notifications.requests).toEqual([{ title, body: "body", workspaceId: "ws-1" }]); + expect(result).toMatchObject({ + success: true, + title, + ui_only: { notify: { notifiedVia: "electron", workspaceId: "ws-1" } }, + }); + }); + + it("falls back to the browser when desktopNotifications shows nothing", async () => { + const notifications = createRecordingDesktopNotifications({ success: false, error: "no" }); + const tool = createNotifyTool({ ...config, desktopNotifications: notifications }); + const execute = tool.execute as (args: { title: string }) => Promise; + + const result = await execute({ title: "Done" }); + + expect(notifications.requests).toHaveLength(1); + expect(result).toMatchObject({ + success: true, + ui_only: { notify: { notifiedVia: "browser" } }, + }); + }); }); diff --git a/src/node/services/tools/notify.ts b/src/node/services/tools/notify.ts index 20fa988172d..d8a45b35b34 100644 --- a/src/node/services/tools/notify.ts +++ b/src/node/services/tools/notify.ts @@ -14,94 +14,12 @@ import { tool } from "ai"; import type { ToolFactory } from "@/common/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import type { NotifyToolResult } from "@/common/types/tools"; -import { getErrorMessage } from "@/common/utils/errors"; - -/** Maximum notification body length (macOS limit is 256 bytes) */ -const MAX_NOTIFICATION_BODY_LENGTH = 200; - -/** Maximum notification title length */ -const MAX_NOTIFICATION_TITLE_LENGTH = 64; - -/** - * Truncates text to a maximum length, adding ellipsis if truncated - */ -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - return text.slice(0, maxLength - 1) + "…"; -} - -/** - * Check if we're running in Electron environment - */ -function isElectronEnvironment(): boolean { - return typeof process.versions.electron === "string"; -} - -/** - * Send a system notification using Electron's Notification API. - * When clicked, focuses the app and navigates to the specified workspace. - * Returns true if notification was shown, false otherwise. - */ -async function sendElectronNotification( - title: string, - body?: string, - workspaceId?: string -): Promise<{ success: boolean; error?: string }> { - if (!isElectronEnvironment()) { - return { - success: false, - error: "System notifications not supported (not running in Electron)", - }; - } - - try { - // eslint-disable-next-line no-restricted-syntax -- Electron is unavailable in `mux server`; avoid top-level import - const { Notification, BrowserWindow } = await import("electron"); - - if (!Notification.isSupported()) { - return { - success: false, - error: "System notifications not supported on this platform", - }; - } - - const notification = new Notification({ - title: truncateText(title, MAX_NOTIFICATION_TITLE_LENGTH), - body: body ? truncateText(body, MAX_NOTIFICATION_BODY_LENGTH) : undefined, - silent: false, - }); - - // Handle notification click - focus app and navigate to workspace - notification.on("click", () => { - const windows = BrowserWindow.getAllWindows(); - const mainWindow = windows[0]; - if (mainWindow) { - // Restore if minimized, then focus - if (mainWindow.isMinimized()) { - mainWindow.restore(); - } - mainWindow.focus(); - - // Send IPC message to renderer to navigate to workspace - if (workspaceId) { - mainWindow.webContents.send("mux:notification-clicked", { workspaceId }); - } - } - }); - - notification.show(); - - return { success: true }; - } catch (error) { - const message = getErrorMessage(error); - return { - success: false, - error: `Failed to send notification: ${message}`, - }; - } -} +import { + MAX_NOTIFICATION_BODY_LENGTH, + MAX_NOTIFICATION_TITLE_LENGTH, + truncateText, +} from "@/node/services/desktopNotifications/desktopNotifications"; +import { electronDesktopNotifications } from "@/node/services/desktopNotifications/electronDesktopNotifications"; /** * Notify tool factory for AI assistant @@ -128,11 +46,13 @@ export const createNotifyTool: ToolFactory = (config) => { ? truncateText(trimmedMessage, MAX_NOTIFICATION_BODY_LENGTH) : undefined; - const result = await sendElectronNotification( - truncatedTitle, - truncatedMessage, - config.workspaceId - ); + // Absent: the Node layer, for configs that tests build by hand. + const notifications = config.desktopNotifications ?? electronDesktopNotifications; + const result = await notifications.show({ + title: truncatedTitle, + body: truncatedMessage, + workspaceId: config.workspaceId, + }); if (config.workspaceId != null && config.timelineService != null) { config.timelineService.record(config.workspaceId, { diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index f6ac13c097e..8aca8e0d053 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -60,6 +60,7 @@ async function createPreparationHarness( workspaceMcpOverridesService: new WorkspaceMcpOverridesService(config), lastLlmRequestByWorkspace: new Map(), bindings: {}, + hostServices: {}, emit: () => false, createAbortedTurnHandle: (messageId) => ({ messageId, diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 9d3ca20252f..644acdc3938 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -102,6 +102,7 @@ import { DEFAULT_ROUTE_PRIORITY } from "@/common/routing"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { getTotalCost, sumUsageHistory } from "@/common/utils/tokens/usageAggregator"; import type { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; +import type { DesktopNotifications } from "@/node/services/desktopNotifications/desktopNotifications"; import type { BashAiProxyService } from "@/node/services/bashAiProxy/bashAiProxyService"; import type { ComputerUseService } from "@/node/services/computerUse/computerUseService"; import type { DevToolsService } from "@/node/services/devToolsService"; @@ -953,6 +954,15 @@ function requireWorkflowArchiveAdmission( return guard; } +/** + * Host services that tools read through ToolConfiguration instead of importing host modules + * (local/no-host-imports). AILive passes the Node layers; an absent member means the tool's + * own Node default. + */ +export interface LoopHostServices { + desktopNotifications?: DesktopNotifications; +} + interface TurnRequestBuilderDependencies { config: Config; providersConfigStore: ProvidersConfigStore; @@ -973,6 +983,7 @@ interface TurnRequestBuilderDependencies { set(workspaceId: string, snapshot: DebugLlmRequestSnapshot): unknown; }; bindings: TurnRequestBuilderBindings; + hostServices: LoopHostServices; emit: (event: string, ...args: unknown[]) => boolean; createAbortedTurnHandle: (messageId: string, signal?: AbortSignal) => TurnStreamHandle; createSettledTurnHandle: (messageId: string, completion: TurnCompletion) => TurnStreamHandle; @@ -2899,6 +2910,7 @@ export class TurnRequestBuilder { strictAgentResolution, xumScope, timelineService: this.dependencies.bindings.timelineService, + desktopNotifications: this.dependencies.hostServices.desktopNotifications, // Agent-scheduled heartbeats start paid turns unattended, so they are user opt-in. workspaceHeartbeatService: this.dependencies.config.loadConfigOrDefault().agentHeartbeatsEnabled === true