Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/src/hooks/helpers/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { formatElapsedTime } from '../../utils/format-elapsed-time'
import { processImagesForMessage } from '../../utils/image-processor'
import { logger } from '../../utils/logger'
import { notifyTaskComplete } from '../../utils/notification'
import { appendInterruptionNotice } from '../../utils/message-block-helpers'
import { getUserMessage } from '../../utils/message-history'
import {
Expand Down Expand Up @@ -466,6 +467,10 @@ export const handleRunCompletion = (params: {
})
const timerResult = timerController.stop('success')

// Show a desktop notification on task completion so users who have
// muted speakers or stepped away see a visual alert (#1111).
notifyTaskComplete()

if (agentMode === 'PLAN') {
setHasReceivedPlanResponse(true)
}
Expand Down
83 changes: 83 additions & 0 deletions cli/src/utils/notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Desktop notification utilities for task completion alerts.
*
* On Windows, fires a native toast notification via PowerShell so users who
* have muted speakers or stepped away still see a visual alert when the
* agent finishes its work (#1111). On other platforms the function is a
* no-op — the terminal bell (BEL character in the OSC title sequence) already
* provides the audible cue.
*
* The Desktop (Electron) app can call `notifyTaskComplete` directly; the
* CLI terminal-app path is best served by the BEL character already embedded
* in `setTerminalTitle`, so this module is not wired into the CLI streaming
* flow by default. The Desktop renderer imports and calls it after its own
* task-completion event.
*/

import { spawn } from 'child_process'

/**
* Show a Windows toast notification with the given title and body.
*
* Uses `urn:github:Electron` as the AUMID so the notification groups under
* the app icon in the action center. PowerShell's `BurntToast` module is
* not guaranteed to be installed, so we fall back to a direct .NET call via
* `powershell -Command` which works on every Windows 10+ machine without
* extra dependencies.
*/
export function notifyDesktop(title: string, body: string): void {
if (process.platform !== 'win32') return

try {
const ps = [
'[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
'[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null',
`$template = @"`,
`<toast launch="action=open" activationType="protocol">`,
` <visual>`,
` <binding template="ToastGeneric">`,
` <text>${escapeXml(title)}</text>`,
` <text>${escapeXml(body)}</text>`,
` </binding>`,
` </visual>`,
`</toast>`,
`"@`,
`$xml = New-Object Windows.Data.Xml.Dom.XmlDocument`,
`$xml.LoadXml($template)`,
`$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)`,
`$toast.Tag = "freebuff-task-complete"`,
`$toast.Group = "freebuff"`,
`$toast.ExpirationTime = [DateTimeOffset]::Now.AddMinutes(5)`,
`[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("freebuff").Show($toast)`,
].join('\n')

const child = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], {
detached: true,
stdio: 'ignore',
windowsHide: true,
})
child.unref()
} catch {
// Notification is best-effort; never break the user's session over it.
}
}

/**
* Convenience wrapper used by the Desktop app after a run completes.
*/
export function notifyTaskComplete(agentName?: string): void {
const title = agentName ? `${agentName} finished` : 'Task complete'
const body = agentName
? `${agentName} has finished processing your request.`
: 'Your request has finished processing.'
notifyDesktop(title, body)
}

/** Escape XML special characters for the PowerShell toast template. */
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
13 changes: 13 additions & 0 deletions common/src/constants/model-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const ALLOWED_MODEL_PREFIXES = [
'minimax',
'mimo',
'tencent',
'friendli',
] as const

export const costModes = [
Expand Down Expand Up @@ -83,6 +84,15 @@ export const minimaxModels = {
} as const
export type MiniMaxModel = (typeof minimaxModels)[keyof typeof minimaxModels]

// FriendliAI serverless models — backup lanes for models already routed via
// other providers. Model IDs use exact HuggingFace casing; the private backend
// adds the matching upstream routing.
export const friendliModels = {
friendli_glm52: 'friendli/zai-org/GLM-5.2',
friendli_miniMaxM25: 'friendli/MiniMaxAI/MiniMax-M2.5',
} as const
export type FriendliModel = (typeof friendliModels)[keyof typeof friendliModels]

export const moonshotModels = {
kimiK26: 'moonshotai/kimi-k2.6',
kimiK27Code: 'moonshotai/kimi-k2.7-code',
Expand Down Expand Up @@ -122,6 +132,7 @@ export const models = {
...mimoModels,
...minimaxModels,
...openrouterModels,
...friendliModels,
...finetunedVertexModels,
} as const

Expand Down Expand Up @@ -254,6 +265,7 @@ export const providerDomains = {
mimo: 'xiaomi.com',
tencent: 'tencent.com',
xai: 'x.ai',
friendli: 'friendli.ai',
} as const

export function getLogoForModel(modelName: string): string | undefined {
Expand All @@ -268,6 +280,7 @@ export function getLogoForModel(modelName: string): string | undefined {
else if (Object.values(mimoModels).includes(modelName as MimoModel))
domain = providerDomains.mimo
else if (modelName.startsWith('tencent/')) domain = providerDomains.tencent
else if (modelName.startsWith('friendli/')) domain = providerDomains.friendli
else if (modelName.includes('claude')) domain = providerDomains.anthropic
else if (modelName.includes('grok')) domain = providerDomains.xai

Expand Down
Loading