Windows notification when Claude finishes a long turn or waits for you while the terminal is in the background; Telegram when you are away / Уведомление…

A Windows notification when Claude finishes a long turn or waits for you while the terminal is in the background; Telegram when you are away.
You start a long task, switch to the browser and forget about it, while Claude has long been done or stuck on a permission prompt. This mod shows a Windows notification when:
AskUserQuestion), a plan to approve, an MCP server's dialog.It only shows one while the terminal (Windows Terminal, VS Code, the desktop app: whichever window Claude Code runs in) is not in front, so it does not fire while you are watching. An interrupted turn and subagents' turns are not announced.
If notifications are turned off in Windows (Settings → System → Notifications, or just for Windows PowerShell), the mod says so once and, instead of a toast, flashes the terminal's taskbar button until you switch to it and plays the system notification sound.
With a Telegram bot set up, the same message also goes to Telegram once the keyboard and mouse have been idle for 5 minutes, so it reaches you when you have left the computer.
/notify shows the state, /notify test sends a test notification, /notify off and /notify on mute and unmute it for the session.
On each notification runs two short PowerShell scripts: one reads which window is in front, how long the input has been idle and whether Windows notifications are on (GetForegroundWindow, GetLastInputInfo, Get-CimInstance Win32_Process, two registry values under HKCU\Software\Microsoft\Windows\CurrentVersion), the other shows a toast under the Windows PowerShell app id or, with notifications off, flashes the taskbar button (FlashWindowEx) and plays a sound. The same check runs once at session start for the warning; whether it was given is kept in the mod's store. With Telegram set up, sends one sendMessage request to api.telegram.org per notification. Adds the /notify command. Notifications work on Windows only; elsewhere only Telegram does.
/config)language: auto (default; follows LANG, else the Windows UI language or regional format), ru or en.toast: unfocused (default; only while the terminal is not in front), always or never.minSeconds: the shortest turn that is announced, 30 by default. A wait for permission or an answer is always announced.awayMinutes: after how many idle minutes Telegram is used, 5 by default.telegramToken: the bot token from @BotFather (secret: asked when the mod is installed, kept in secure storage).telegramChatId: your chat id with the bot (send the bot any message, then open https://api.telegram.org/bot<token>/getUpdates, or ask @userinfobot). Write to the bot first, or it cannot write to you./plugin install turn-notify --marketplace ArtyaEXE/claude-mods
Part of claude-mods. MIT license.
Уведомление Windows, когда Claude закончил долгий ход или ждёт вас, а терминал в фоне; Telegram, если вы отошли.
Запускаешь долгую задачу, переключаешься в браузер и забываешь, а Claude давно закончил или застрял на запросе разрешения. Мод показывает уведомление Windows, когда:
AskUserQuestion), план на одобрение, диалог MCP-сервера.Уведомление приходит, только если окно терминала (Windows Terminal, VS Code, десктопное приложение — в чём запущен Claude Code) не на переднем плане, поэтому, пока вы смотрите, оно не мешает. О прерванном ходе и ходах субагентов мод не сообщает.
Если уведомления в Windows выключены (Параметры → Система → Уведомления, целиком или только для Windows PowerShell), мод один раз об этом предупредит и вместо toast'а будет мигать кнопкой терминала на панели задач, пока вы на неё не переключитесь, и подавать системный звук уведомления.
Если настроен Telegram-бот, то же сообщение уходит и в Telegram, когда клавиатура и мышь простаивают 5 минут: так оно найдёт вас, даже если вы отошли от компьютера.
/notify показывает состояние, /notify test присылает тестовое уведомление, /notify off и /notify on выключают и включают уведомления до конца сессии.
На каждое уведомление запускает два коротких PowerShell-скрипта: первый читает, какое окно на переднем плане, сколько простаивает ввод и включены ли уведомления Windows (GetForegroundWindow, GetLastInputInfo, Get-CimInstance Win32_Process, два значения реестра в HKCU\Software\Microsoft\Windows\CurrentVersion), второй показывает toast от имени Windows PowerShell или, если уведомления выключены, мигает кнопкой на панели задач (FlashWindowEx) и подаёт звук. Та же проверка один раз запускается при старте сессии ради предупреждения; было ли оно уже показано, мод хранит в своём хранилище. Если настроен Telegram, на каждое уведомление отправляет один запрос sendMessage в api.telegram.org. Добавляет команду /notify. Уведомления Windows работают только на Windows, в других системах — только Telegram.
/config)language: auto (по умолчанию; по LANG, иначе по языку интерфейса или региональному формату Windows), ru или en.toast: unfocused (по умолчанию; только когда терминал не на переднем плане), always или never.minSeconds: самый короткий ход, о котором сообщать, по умолчанию 30 секунд. Об ожидании разрешения или ответа мод сообщает всегда.awayMinutes: через сколько минут простоя писать в Telegram, по умолчанию 5.telegramToken: токен бота от @BotFather (секрет: спрашивается при установке, хранится в защищённом хранилище).telegramChatId: ID вашего чата с ботом (напишите боту любое сообщение и откройте https://api.telegram.org/bot<токен>/getUpdates или спросите @userinfobot). Сначала напишите боту сами, иначе он не сможет написать вам./plugin install turn-notify --marketplace ArtyaEXE/claude-mods
Часть claude-mods. Лицензия MIT.
hooks/register.ts 418 lines1import type { EngineInterface, Register } from 'claude-code'
2
3type Lang = 'ru' | 'en'
4
5// `ru` / `en` as set; `auto`: the locale variables first, then Windows' UI language or regional format.
6async function resolveLang($: EngineInterface, setting: unknown): Promise<Lang> {
7 if (setting === 'ru' || setting === 'en') return setting
8 const env = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
9 if (env) return env.toLowerCase().startsWith('ru') ? 'ru' : 'en'
10 try {
11 const ran = await $.process.run(
12 ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name; (Get-Culture).Name'],
13 { timeoutMs: 10_000 },
14 )
15 if (ran.exitCode === 0 && /^ru\b/im.test(ran.stdout)) return 'ru'
16 } catch {
17 // Not Windows: English.
18 }
19
20 return 'en'
21}
22
23type Options = {
24 language?: unknown
25 toast?: unknown
26 minSeconds?: unknown
27 awayMinutes?: unknown
28 telegramToken?: unknown
29 telegramChatId?: unknown
30}
31
32// One line of JSON: whether a window of this process's ancestors (the terminal, VS Code, the desktop app)
33// is in front, that window's handle, how long the keyboard and mouse have been idle, and whether Windows
34// shows toasts at all (`off`: the system switch) and PowerShell's in particular (`app-off`).
35const PROBE = String.raw`
36$ProgressPreference = 'SilentlyContinue'
37Add-Type -Namespace TurnNotify -Name Win32 -MemberDefinition @'
38[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
39[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
40[StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public uint dwTime; }
41[DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO i);
42'@
43$fg = 0
44[void][TurnNotify.Win32]::GetWindowThreadProcessId([TurnNotify.Win32]::GetForegroundWindow(), [ref]$fg)
45$parents = @{}
46Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId | ForEach-Object { $parents[[int]$_.ProcessId] = [int]$_.ParentProcessId }
47$focused = $false
48$hwnd = 0
49$p = $parents[$PID]
50for ($i = 0; $i -lt 32 -and $p -gt 0; $i++) {
51 if ($p -eq $fg) { $focused = $true }
52 if ($hwnd -eq 0) {
53 $proc = Get-Process -Id $p -ErrorAction SilentlyContinue
54 if ($proc -and $proc.MainWindowHandle -ne [IntPtr]::Zero) { $hwnd = [long]$proc.MainWindowHandle }
55 }
56 $p = $parents[$p]
57}
58$li = New-Object TurnNotify.Win32+LASTINPUTINFO
59$li.cbSize = 8
60[void][TurnNotify.Win32]::GetLastInputInfo([ref]$li)
61$idle = [Environment]::TickCount - [int]$li.dwTime
62$k = 'HKCU:\Software\Microsoft\Windows\CurrentVersion'
63$all = (Get-ItemProperty "$k\PushNotifications" -ErrorAction SilentlyContinue).ToastEnabled -ne 0
64$app = (Get-ItemProperty "$k\Notifications\Settings\{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe" -ErrorAction SilentlyContinue).Enabled -ne 0
65$toasts = if (-not $all) { 'off' } elseif (-not $app) { 'app-off' } else { 'on' }
66[pscustomobject]@{ focused = $focused; idleMs = $idle; toasts = $toasts; hwnd = $hwnd } | ConvertTo-Json -Compress
67`
68
69// A Windows toast from TURN_NOTIFY_TITLE and TURN_NOTIFY_BODY, under PowerShell's own app id (no registration needed).
70const TOAST = String.raw`
71$ProgressPreference = 'SilentlyContinue'
72[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
73[void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
74$t = [Security.SecurityElement]::Escape($env:TURN_NOTIFY_TITLE)
75$b = [Security.SecurityElement]::Escape($env:TURN_NOTIFY_BODY)
76$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
77$xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text>' + $t + '</text><text>' + $b + '</text></binding></visual></toast>')
78$app = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
79[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($app).Show([Windows.UI.Notifications.ToastNotification]::new($xml))
80`
81
82// Where Windows shows no toasts: the terminal's taskbar button flashes until it is brought to the front
83// (FLASHW_ALL | FLASHW_TIMERNOFG), and the system notification sound plays.
84const FLASH = String.raw`
85$ProgressPreference = 'SilentlyContinue'
86trap { [Console]::Out.WriteLine($_.Exception.Message); exit 1 }
87Add-Type -Namespace TurnNotify -Name Flash -MemberDefinition @'
88[StructLayout(LayoutKind.Sequential)] public struct FLASHWINFO { public uint cbSize; public IntPtr hwnd; public uint dwFlags; public uint uCount; public uint dwTimeout; }
89[DllImport("user32.dll")] public static extern bool FlashWindowEx(ref FLASHWINFO f);
90'@
91$h = [long]$env:TURN_NOTIFY_HWND
92if ($h -ne 0) {
93 $f = New-Object TurnNotify.Flash+FLASHWINFO
94 $f.cbSize = [Runtime.InteropServices.Marshal]::SizeOf($f)
95 $f.hwnd = [IntPtr]$h
96 $f.dwFlags = 15
97 [void][TurnNotify.Flash]::FlashWindowEx([ref]$f)
98}
99$wav = (Get-ItemProperty 'HKCU:\AppEvents\Schemes\Apps\.Default\Notification.Default\.Current' -ErrorAction SilentlyContinue).'(default)'
100if ($wav) { $wav = [Environment]::ExpandEnvironmentVariables($wav) }
101if (-not $wav -or -not (Test-Path -LiteralPath $wav)) { $wav = Join-Path ([Environment]::GetFolderPath('Windows')) 'Media\Windows Notify System Generic.wav' }
102if (-not (Test-Path -LiteralPath $wav)) { throw "no notification sound: $wav" }
103(New-Object System.Media.SoundPlayer $wav).PlaySync()
104`
105
106const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
107
108// `-EncodedCommand` takes the script as base64 UTF-16LE: Windows' argv quoting cannot strip its quotes.
109function encodeCommand(script: string): string {
110 const bytes: number[] = []
111 for (let i = 0; i < script.length; i++) {
112 const c = script.charCodeAt(i)
113 bytes.push(c & 0xff, c >> 8)
114 }
115 let out = ''
116 for (let i = 0; i < bytes.length; i += 3) {
117 const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
118 out += B64.charAt((n >> 18) & 63) + B64.charAt((n >> 12) & 63)
119 out += i + 1 < bytes.length ? B64.charAt((n >> 6) & 63) : '='
120 out += i + 2 < bytes.length ? B64.charAt(n & 63) : '='
121 }
122
123 return out
124}
125
126const powershell = (script: string) => ['powershell.exe', '-NoProfile', '-NonInteractive', '-EncodedCommand', encodeCommand(script)]
127
128type Toasts = 'on' | 'off' | 'app-off'
129
130type Probe = { focused: boolean; idleMs: number; toasts: Toasts; hwnd: number }
131
132// What one notification came to: shown as a toast, flashed instead (and why, and PowerShell's error if
133// the flash or its sound failed), or not shown on Windows; Telegram's HTTP status when it was sent (0: the request failed).
134type Outcome = { local: 'toast' | 'flash' | 'none' | 'unknown'; toasts?: Toasts; error?: string; telegram?: number }
135
136const STRINGS = {
137 ru: {
138 duration: (s: number) => (s < 60 ? `${s} с` : s < 3600 ? `${Math.floor(s / 60)} мин ${s % 60} с` : `${Math.floor(s / 3600)} ч ${Math.floor(s / 60) % 60} мин`),
139 done: (d: string) => `Готово за ${d}`,
140 error: (d: string) => `Ход оборвался с ошибкой через ${d}`,
141 refusal: 'Модель отказалась отвечать',
142 permission: 'Ждёт разрешения',
143 question: 'Задаёт вопрос',
144 plan: 'План готов, ждёт одобрения',
145 command: 'Уведомления о конце хода: /notify test | on | off',
146 test: 'Проверка уведомления',
147 testBody: 'Если вы это видите, уведомления работают.',
148 off: {
149 off: 'Уведомления Windows выключены в системе (Параметры → Система → Уведомления)',
150 'app-off': 'Уведомления Windows выключены для Windows PowerShell (Параметры → Система → Уведомления → Windows PowerShell)',
151 },
152 fallback: 'вместо них мигаю кнопкой терминала на панели задач и подаю звук',
153 warn: (why: string) => `turn-notify: ${why}. Пока они выключены, мигаю кнопкой терминала на панели задач и подаю звук.`,
154 shown: {
155 toast: 'Показал уведомление Windows.',
156 flash: (why: string) => `${why}, поэтому мигнул кнопкой терминала на панели задач и подал звук.`,
157 none: 'В Windows ничего не показал: toast = never.',
158 unknown: 'В Windows ничего не показал: PowerShell не ответил (не Windows?).',
159 },
160 flashFailed: (error: string) => ` Но PowerShell сообщил об ошибке: ${error}`,
161 telegramSent: ' Отправил в Telegram.',
162 telegramFailed: (status: number) => (status === 0 ? ' Telegram недоступен.' : ` Telegram ответил ${status}: проверьте токен и ID чата.`),
163 telegramFailedToast: (status: number) => `turn-notify: Telegram ответил ${status}`,
164 muted: 'Уведомления выключены до конца сессии. Включить: /notify on',
165 unmuted: 'Уведомления включены.',
166 status: (muted: boolean, min: number, windows: string, telegram: boolean) =>
167 `Уведомления ${muted ? 'выключены' : 'включены'}: ход дольше ${min} с, ожидание разрешения или ответа. ${windows}. Telegram ${telegram ? 'настроен' : 'не настроен'}. /notify test | on | off`,
168 windowsOn: 'Уведомления Windows включены',
169 },
170 en: {
171 duration: (s: number) => (s < 60 ? `${s} s` : s < 3600 ? `${Math.floor(s / 60)} min ${s % 60} s` : `${Math.floor(s / 3600)} h ${Math.floor(s / 60) % 60} min`),
172 done: (d: string) => `Done in ${d}`,
173 error: (d: string) => `The turn died on an error after ${d}`,
174 refusal: 'The model refused to answer',
175 permission: 'Waiting for permission',
176 question: 'Asking you a question',
177 plan: 'The plan is ready for approval',
178 command: 'Turn notifications: /notify test | on | off',
179 test: 'Test notification',
180 testBody: 'If you can see this, notifications work.',
181 off: {
182 off: 'Windows notifications are turned off (Settings → System → Notifications)',
183 'app-off': 'Windows notifications are turned off for Windows PowerShell (Settings → System → Notifications → Windows PowerShell)',
184 },
185 fallback: "instead I flash the terminal's taskbar button and play a sound",
186 warn: (why: string) => `turn-notify: ${why}. While they are off, I flash the terminal's taskbar button and play a sound instead.`,
187 shown: {
188 toast: 'Showed a Windows notification.',
189 flash: (why: string) => `${why}, so I flashed the terminal's taskbar button and played a sound.`,
190 none: 'Showed nothing on Windows: toast = never.',
191 unknown: 'Showed nothing on Windows: PowerShell did not answer (not Windows?).',
192 },
193 flashFailed: (error: string) => ` But PowerShell reported an error: ${error}`,
194 telegramSent: ' Sent to Telegram.',
195 telegramFailed: (status: number) => (status === 0 ? ' Telegram is unreachable.' : ` Telegram answered ${status}: check the token and chat id.`),
196 telegramFailedToast: (status: number) => `turn-notify: Telegram answered ${status}`,
197 muted: 'Notifications are off for the rest of the session. Back on: /notify on',
198 unmuted: 'Notifications are on.',
199 status: (muted: boolean, min: number, windows: string, telegram: boolean) =>
200 `Notifications are ${muted ? 'off' : 'on'}: turns over ${min} s, waits for permission or an answer. ${windows}. Telegram ${telegram ? 'set up' : 'not set up'}. /notify test | on | off`,
201 windowsOn: 'Windows notifications are on',
202 },
203}
204
205// The first line of an answer that says something, without Markdown marks, cut short.
206function gist(answer: string, max = 180): string {
207 const line =
208 answer
209 .split('\n')
210 .map(l => l.replace(/^\s*(#+|[-*+>]|\d+\.)\s+/, '').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim())
211 .find(l => l !== '' && !/^```/.test(l)) ?? ''
212
213 return line.length > max ? `${line.slice(0, max - 1)}…` : line
214}
215
216const folderName = (cwd: string) => cwd.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || cwd
217
218let lang: Lang = 'en'
219let toastMode: 'unfocused' | 'always' | 'never' = 'unfocused'
220let minSeconds = 30
221let awayMs = 5 * 60_000
222let token = ''
223let chatId = ''
224let interactive = true
225let muted = false
226let lastSentAt = 0
227
228const hasTelegram = () => token !== '' && chatId !== ''
229
230async function probe($: EngineInterface): Promise<Probe | undefined> {
231 try {
232 const ran = await $.process.run(powershell(PROBE), { timeoutMs: 15_000 })
233 if (ran.exitCode !== 0) return undefined
234 const parsed = JSON.parse(ran.stdout.trim()) as Partial<Probe>
235 const toasts = parsed.toasts === 'off' || parsed.toasts === 'app-off' ? parsed.toasts : 'on'
236
237 return { focused: parsed.focused === true, idleMs: Number(parsed.idleMs) || 0, toasts, hwnd: Number(parsed.hwnd) || 0 }
238 } catch {
239 // Not Windows, or PowerShell could not tell.
240 return undefined
241 }
242}
243
244async function toast($: EngineInterface, title: string, body: string) {
245 try {
246 await $.process.run(powershell(TOAST), {
247 timeoutMs: 15_000,
248 env: { TURN_NOTIFY_TITLE: title, TURN_NOTIFY_BODY: body },
249 })
250 } catch {
251 // No toasts here: nothing to show.
252 }
253}
254
255// PowerShell's error when the flash or the sound failed, '' when both went through.
256async function flash($: EngineInterface, hwnd: number): Promise<string> {
257 try {
258 const ran = await $.process.run(powershell(FLASH), { timeoutMs: 15_000, env: { TURN_NOTIFY_HWND: String(hwnd) } })
259
260 return ran.exitCode === 0 ? '' : ran.stdout.replace(/\s+/g, ' ').trim().slice(0, 300) || `exit ${ran.exitCode}`
261 } catch (error) {
262 return String(error).slice(0, 300)
263 }
264}
265
266// Telegram's HTTP status, 0 when the request did not go through.
267async function telegram($: EngineInterface, title: string, body: string): Promise<number> {
268 try {
269 const sent = await $.http.fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
270 method: 'POST',
271 headers: { 'content-type': 'application/json' },
272 body: JSON.stringify({ chat_id: chatId, text: body ? `${title}\n${body}` : title, disable_web_page_preview: true }),
273 })
274
275 return sent.status
276 } catch {
277 return 0
278 }
279}
280
281// Once per machine while Windows shows no toasts: say so, and that the taskbar flashes instead.
282async function warnIfToastsOff($: EngineInterface) {
283 const seen = await probe($)
284 if (seen === undefined) return
285 if (seen.toasts === 'on') {
286 await $.store.delete('warnedToastsOff')
287
288 return
289 }
290 if ((await $.store.get('warnedToastsOff')) === seen.toasts) return
291 await $.store.set('warnedToastsOff', seen.toasts)
292 $.ui.toast(STRINGS[lang].warn(STRINGS[lang].off[seen.toasts]))
293}
294
295// A toast while the terminal is not in front (the taskbar flashes where Windows shows no toasts);
296// Telegram once the person has been away a while.
297async function notify($: EngineInterface, headline: string, body: string, force = false): Promise<Outcome | undefined> {
298 if (!force && (muted || !interactive)) return undefined
299 const at = Date.now()
300 // A question can raise both the tool call and the engine's own notification: one is enough.
301 if (!force && at - lastSentAt < 3_000) return undefined
302 lastSentAt = at
303
304 let folder = ''
305 try {
306 folder = folderName(await $.session.cwd())
307 } catch {
308 // No folder in the title then.
309 }
310 const title = folder ? `Claude Code · ${folder}: ${headline}` : `Claude Code: ${headline}`
311 const seen = await probe($)
312 const outcome: Outcome = { local: seen === undefined ? 'unknown' : 'none', toasts: seen?.toasts }
313 if (toastMode !== 'never' && seen !== undefined && (force || toastMode === 'always' || !seen.focused)) {
314 if (seen.toasts === 'on') {
315 await toast($, title, body)
316 outcome.local = 'toast'
317 } else {
318 outcome.error = (await flash($, seen.hwnd)) || undefined
319 outcome.local = 'flash'
320 }
321 }
322 // Where idle time cannot be read (not Windows), Telegram is the only way to reach the person.
323 if (hasTelegram() && (force || seen === undefined || seen.idleMs >= awayMs)) {
324 outcome.telegram = await telegram($, title, body)
325 if (!force && outcome.telegram !== 200) $.ui.toast(STRINGS[lang].telegramFailedToast(outcome.telegram))
326 }
327
328 return outcome
329}
330
331export const register: Register = (on, options) => {
332 const o = options as Options
333 toastMode = o.toast === 'always' || o.toast === 'never' ? o.toast : 'unfocused'
334 minSeconds = typeof o.minSeconds === 'number' && o.minSeconds >= 0 ? o.minSeconds : 30
335 awayMs = (typeof o.awayMinutes === 'number' && o.awayMinutes >= 0 ? o.awayMinutes : 5) * 60_000
336 token = typeof o.telegramToken === 'string' ? o.telegramToken.trim() : ''
337 chatId = typeof o.telegramChatId === 'string' ? o.telegramChatId.trim() : ''
338
339 on('session.start', async ($, e, next) => {
340 const started = await next(e)
341 interactive = e.isInteractive !== false
342 lang = await resolveLang($, o.language)
343 await $.command.register({ name: 'notify', description: STRINGS[lang].command, argumentHint: 'test | on | off' })
344 if (interactive && toastMode !== 'never') void warnIfToastsOff($)
345
346 return started
347 })
348
349 on('command.run', { command: 'notify' }, async ($, e) => {
350 const L = STRINGS[lang]
351 const arg = e.args.trim().toLowerCase()
352 if (arg === 'off') {
353 muted = true
354
355 return { text: L.muted }
356 }
357 if (arg === 'on') {
358 muted = false
359
360 return { text: L.unmuted }
361 }
362 if (arg === 'test') {
363 const outcome = await notify($, L.test, L.testBody, true)
364 if (outcome === undefined) return { text: L.shown.unknown }
365 let text =
366 outcome.local === 'flash' && outcome.toasts !== undefined && outcome.toasts !== 'on'
367 ? L.shown.flash(L.off[outcome.toasts])
368 : outcome.local === 'toast'
369 ? L.shown.toast
370 : outcome.local === 'none'
371 ? L.shown.none
372 : L.shown.unknown
373 if (outcome.error !== undefined) text += L.flashFailed(outcome.error)
374 if (outcome.telegram !== undefined) text += outcome.telegram === 200 ? L.telegramSent : L.telegramFailed(outcome.telegram)
375
376 return { text }
377 }
378
379 const seen = await probe($)
380 const windows = seen === undefined || seen.toasts === 'on' ? L.windowsOn : `${L.off[seen.toasts]}: ${L.fallback}`
381
382 return { text: L.status(muted, minSeconds, windows, hasTelegram()) }
383 })
384
385 on('turn.complete', async ($, e, next) => {
386 const done = await next(e)
387 if (e.agentId !== undefined || e.reason === 'aborted') return done
388 const L = STRINGS[lang]
389 const took = L.duration(Math.round(e.durationMs / 1000))
390 if (e.reason === 'refusal') void notify($, L.refusal, '')
391 else if (e.reason === 'error') void notify($, L.error(took), '')
392 else if (e.durationMs >= minSeconds * 1000) void notify($, L.done(took), gist(e.answer))
393
394 return done
395 })
396
397 // The engine asks the person to allow a tool, or an MCP server asks them something.
398 on('classic.Notification', async ($, e, next) => {
399 if (e.notification_type === 'permission_prompt' || e.notification_type === 'elicitation_dialog') {
400 void notify($, STRINGS[lang].permission, e.message)
401 }
402
403 return next(e)
404 })
405
406 // A question or a plan holds the turn until the person answers: say so before it waits.
407 on('tool.call', async ($, e, next) => {
408 if (e.tool === 'AskUserQuestion') {
409 const first = (e as { questions?: { question?: string }[] }).questions?.[0]?.question ?? ''
410 void notify($, STRINGS[lang].question, gist(first))
411 } else if (e.tool === 'ExitPlanMode') {
412 void notify($, STRINGS[lang].plan, '')
413 }
414
415 return next(e)
416 })
417}
418