SLOPSHOPPER

turn-notify

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

newguardcommandtoastprocessnetwork
★ 1v1.0.0MITupdated 2026-10-26ArtyaEXE/claude-mods/turn-notify
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-notify
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /notify ⎿ turn-notify: Notifications are on: turns over 30 s, waits for permission or an answer. Windows notifications are on. Telegr ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

turn-notify

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.

English · Русский

English

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:

  • a turn that ran 30 seconds or more ends: how long it took and the first line of the answer;
  • a turn ends on an API error, or the model refuses;
  • Claude waits for you: a permission prompt, a question (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.

What it touches

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.

Settings (/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.

Install

/plugin install turn-notify --marketplace ArtyaEXE/claude-mods

Part of claude-mods. MIT license.


Русский

Уведомление Windows, когда Claude закончил долгий ход или ждёт вас, а терминал в фоне; Telegram, если вы отошли.

Запускаешь долгую задачу, переключаешься в браузер и забываешь, а Claude давно закончил или застрял на запросе разрешения. Мод показывает уведомление Windows, когда:

  • закончился ход, который шёл 30 секунд и дольше: сколько он шёл и первая строка ответа;
  • ход оборвался на ошибке API или модель отказалась отвечать;
  • Claude ждёт вас: запрос разрешения, вопрос (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.

Source 1 files
hooks/register.ts 418 lines
1import 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