pi-smart-voice-notify 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,860 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import type { SettingItem } from "@mariozechner/pi-tui";
7
+
8
+ import {
9
+ BOOLEAN_VALUES,
10
+ clampInt,
11
+ CONFIG_PATH,
12
+ DEBUG_LOG_PATH,
13
+ DEFAULT_CONFIG,
14
+ DESKTOP_NOTIFICATION_TIMEOUT_VALUES,
15
+ EXTENSION_ID,
16
+ IDLE_THRESHOLD_VALUES,
17
+ INLINE_NOTIFY_TEXT,
18
+ isNotificationEnabled,
19
+ isWindows,
20
+ MAX_FOLLOW_UP_VALUES,
21
+ MESSAGE_LIBRARY,
22
+ normalizeConfig,
23
+ normalizeMode,
24
+ NOTIFICATION_MODES,
25
+ PERMISSION_HINTS,
26
+ QUESTION_HINTS,
27
+ RATE_VALUES,
28
+ readConfigFromDisk,
29
+ REMINDER_DELAY_VALUES,
30
+ STATUS_KEY,
31
+ summarizeConfig,
32
+ writeConfigToDisk,
33
+ boolValue,
34
+ ensureDebugDirectory,
35
+ } from "./config-store.js";
36
+ import { sendDesktopNotification } from "./desktop-notify.js";
37
+ import { createExtensionLogger, getErrorMessage } from "./logging.js";
38
+ import { AudioNotificationService } from "./notify-audio.js";
39
+ import type {
40
+ NotificationType,
41
+ NotifyLevel,
42
+ ReminderState,
43
+ VoiceNotifyConfig,
44
+ } from "./types.js";
45
+ import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
46
+
47
+ function pickRandom<T>(items: readonly T[]): T {
48
+ const index = Math.floor(Math.random() * items.length);
49
+ return items[index] ?? items[0];
50
+ }
51
+
52
+ function extractTextContent(content: unknown): string {
53
+ if (!Array.isArray(content)) {
54
+ return "";
55
+ }
56
+
57
+ const parts: string[] = [];
58
+ for (const item of content) {
59
+ if (!item || typeof item !== "object") {
60
+ continue;
61
+ }
62
+ const record = item as Record<string, unknown>;
63
+ if (record.type === "text" && typeof record.text === "string") {
64
+ parts.push(record.text);
65
+ }
66
+ }
67
+ return parts.join("\n");
68
+ }
69
+
70
+ function classifyToolResult(
71
+ toolName: string,
72
+ isError: boolean,
73
+ textContent: string,
74
+ ): NotificationType | null {
75
+ const normalizedTool = toolName.toLowerCase();
76
+ const normalizedText = textContent.toLowerCase().slice(0, 800);
77
+
78
+ if (!isError) {
79
+ if (normalizedTool.includes("question")) {
80
+ return "question";
81
+ }
82
+ if (QUESTION_HINTS.some((hint) => normalizedText.includes(hint))) {
83
+ return "question";
84
+ }
85
+ return null;
86
+ }
87
+
88
+ if (normalizedTool.includes("question")) {
89
+ return "question";
90
+ }
91
+
92
+ if (normalizedTool.includes("permission") || PERMISSION_HINTS.some((hint) => normalizedText.includes(hint))) {
93
+ return "permission";
94
+ }
95
+
96
+ return "error";
97
+ }
98
+
99
+ function toRecord(value: unknown): Record<string, unknown> {
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
101
+ return {};
102
+ }
103
+ return value as Record<string, unknown>;
104
+ }
105
+
106
+ function readBlockedReason(value: unknown): string | null {
107
+ const record = toRecord(value);
108
+ const blockValue = record.block;
109
+ const isBlocked = blockValue === true || blockValue === "true";
110
+ if (!isBlocked) {
111
+ return null;
112
+ }
113
+
114
+ const reason = record.reason;
115
+ if (typeof reason !== "string") {
116
+ return null;
117
+ }
118
+
119
+ const normalizedReason = reason.trim();
120
+ return normalizedReason.length > 0 ? normalizedReason : null;
121
+ }
122
+
123
+ function extractToolCallBlockReason(event: unknown): string | null {
124
+ const directReason = readBlockedReason(event);
125
+ if (directReason) {
126
+ return directReason;
127
+ }
128
+
129
+ const record = toRecord(event);
130
+ return readBlockedReason(record.result);
131
+ }
132
+
133
+ function isPermissionReason(reason: string): boolean {
134
+ const normalizedReason = reason.toLowerCase();
135
+ return PERMISSION_HINTS.some((hint) => normalizedReason.includes(hint));
136
+ }
137
+
138
+ function statusLine(config: VoiceNotifyConfig): string | undefined {
139
+ if (!config.enabled) {
140
+ return "voice:off";
141
+ }
142
+
143
+ const bits = [
144
+ `voice:${config.notificationMode}`,
145
+ config.enableSound ? "snd" : "no-snd",
146
+ config.enableTts ? "tts" : "no-tts",
147
+ config.enableDesktopNotification ? "toast" : "no-toast",
148
+ ];
149
+ return bits.join(" ");
150
+ }
151
+
152
+ function queueTask(task: Promise<void>, onError: (error: unknown) => void): void {
153
+ void task.catch(onError);
154
+ }
155
+
156
+ export default function smartVoiceNotifyExtension(pi: ExtensionAPI): void {
157
+ let config = readConfigFromDisk();
158
+ let lastUserActivityAt = Date.now();
159
+ let hadErrorInTurn = false;
160
+ let warnedNonWindows = false;
161
+ let warnedDesktopUnsupported = false;
162
+ let audioQueue: Promise<void> = Promise.resolve();
163
+ let questionToolAvailable = false;
164
+
165
+ const pendingReminders = new Map<NotificationType, ReminderState>();
166
+ const processedToolCallIds = new Set<string>();
167
+ const lastNotificationAt = new Map<NotificationType, number>();
168
+
169
+ const logger = createExtensionLogger({
170
+ extensionId: EXTENSION_ID,
171
+ debugLogPath: DEBUG_LOG_PATH,
172
+ isDebugEnabled: () => config.debugLog,
173
+ ensureDebugDirectory,
174
+ });
175
+
176
+ const audioService = new AudioNotificationService({
177
+ execRunner: pi,
178
+ getConfig: () => config,
179
+ debug: logger.debug,
180
+ });
181
+
182
+ const rememberProcessedToolCallId = (toolCallId: string): boolean => {
183
+ if (processedToolCallIds.has(toolCallId)) {
184
+ return false;
185
+ }
186
+ processedToolCallIds.add(toolCallId);
187
+ if (processedToolCallIds.size > 500) {
188
+ processedToolCallIds.clear();
189
+ processedToolCallIds.add(toolCallId);
190
+ }
191
+ return true;
192
+ };
193
+
194
+ const logError = (error: unknown): void => {
195
+ logger.error(error);
196
+ };
197
+
198
+ const refreshQuestionToolAvailability = (): void => {
199
+ try {
200
+ questionToolAvailable = pi.getAllTools().some((tool) => tool.name.toLowerCase() === "question");
201
+ } catch {
202
+ questionToolAvailable = false;
203
+ }
204
+ };
205
+
206
+ const notifyUser = (ctx: ExtensionContext, message: string, level: NotifyLevel): void => {
207
+ if (!ctx.hasUI) {
208
+ return;
209
+ }
210
+ ctx.ui.notify(message, level);
211
+ };
212
+
213
+ const updateStatus = (ctx: ExtensionContext): void => {
214
+ if (!ctx.hasUI) {
215
+ return;
216
+ }
217
+ ctx.ui.setStatus(STATUS_KEY, statusLine(config));
218
+ };
219
+
220
+ const persistConfig = (ctx?: ExtensionContext): void => {
221
+ try {
222
+ writeConfigToDisk(config);
223
+ logger.debug("config.persisted", {
224
+ configPath: CONFIG_PATH,
225
+ debugLogPath: DEBUG_LOG_PATH,
226
+ enabled: config.enabled,
227
+ notificationMode: config.notificationMode,
228
+ });
229
+ } catch (error) {
230
+ if (ctx) {
231
+ notifyUser(ctx, `Failed to save ${EXTENSION_ID} config: ${getErrorMessage(error)}`, "warning");
232
+ }
233
+ logError(error);
234
+ }
235
+ };
236
+
237
+ const enqueueAudio = (job: () => Promise<void>): void => {
238
+ audioQueue = audioQueue.then(job).catch(logError);
239
+ };
240
+
241
+ const cancelReminder = (type: NotificationType): void => {
242
+ const reminder = pendingReminders.get(type);
243
+ if (!reminder) {
244
+ return;
245
+ }
246
+ clearTimeout(reminder.timeoutId);
247
+ pendingReminders.delete(type);
248
+ logger.debug("reminder.cancelled", { type, followUpCount: reminder.followUpCount });
249
+ };
250
+
251
+ const cancelAllReminders = (): void => {
252
+ const count = pendingReminders.size;
253
+ for (const reminder of pendingReminders.values()) {
254
+ clearTimeout(reminder.timeoutId);
255
+ }
256
+ pendingReminders.clear();
257
+ if (count > 0) {
258
+ logger.debug("reminder.cancelled_all", { count });
259
+ }
260
+ };
261
+
262
+ const scheduleReminder = (
263
+ type: NotificationType,
264
+ delaySeconds: number,
265
+ followUpCount: number,
266
+ ): void => {
267
+ if (!config.enabled || !config.reminderEnabled || !config.enableTts || config.notificationMode === "sound-only") {
268
+ return;
269
+ }
270
+
271
+ cancelReminder(type);
272
+ const scheduledAt = Date.now();
273
+ const delayMs = Math.max(1, delaySeconds) * 1000;
274
+
275
+ const timeoutId = setTimeout(() => {
276
+ queueTask(
277
+ (async () => {
278
+ const current = pendingReminders.get(type);
279
+ if (!current || current.scheduledAt !== scheduledAt) {
280
+ return;
281
+ }
282
+
283
+ if (lastUserActivityAt > scheduledAt) {
284
+ pendingReminders.delete(type);
285
+ logger.debug("reminder.skipped_user_active", { type, followUpCount });
286
+ return;
287
+ }
288
+
289
+ const reminderMessage = pickRandom(MESSAGE_LIBRARY[type].reminder);
290
+ logger.debug("reminder.fired", { type, followUpCount, delaySeconds });
291
+ enqueueAudio(async () => {
292
+ await audioService.wakeMonitor();
293
+ await audioService.speakWithSapi(reminderMessage);
294
+ });
295
+
296
+ pendingReminders.delete(type);
297
+ const shouldScheduleFollowUp =
298
+ config.followUpEnabled && followUpCount + 1 < config.maxFollowUps && lastUserActivityAt <= Date.now();
299
+ if (!shouldScheduleFollowUp) {
300
+ return;
301
+ }
302
+
303
+ const nextDelay = Math.round(Math.max(5, delaySeconds * config.followUpBackoffMultiplier));
304
+ logger.debug("reminder.follow_up_scheduled", {
305
+ type,
306
+ followUpCount: followUpCount + 1,
307
+ delaySeconds: nextDelay,
308
+ });
309
+ scheduleReminder(type, nextDelay, followUpCount + 1);
310
+ })(),
311
+ logError,
312
+ );
313
+ }, delayMs);
314
+
315
+ pendingReminders.set(type, { timeoutId, scheduledAt, followUpCount, delaySeconds });
316
+ logger.debug("reminder.scheduled", { type, followUpCount, delaySeconds });
317
+ };
318
+
319
+ const shouldThrottle = (type: NotificationType): boolean => {
320
+ const now = Date.now();
321
+ const last = lastNotificationAt.get(type) ?? 0;
322
+ if (now - last < config.minNotificationIntervalMs) {
323
+ return true;
324
+ }
325
+ lastNotificationAt.set(type, now);
326
+ return false;
327
+ };
328
+
329
+ const dispatchAudio = (type: NotificationType, text: string): void => {
330
+ const mode = config.notificationMode;
331
+ const shouldPlaySoundNow = config.enableSound && (mode === "sound-first" || mode === "both" || mode === "sound-only");
332
+ const shouldSpeakNow = config.enableTts && (mode === "tts-first" || mode === "both");
333
+ const shouldDispatchAudio = shouldPlaySoundNow || shouldSpeakNow;
334
+
335
+ if (shouldDispatchAudio) {
336
+ enqueueAudio(async () => {
337
+ await audioService.wakeMonitor();
338
+ });
339
+ }
340
+
341
+ if (shouldPlaySoundNow) {
342
+ logger.debug("audio.sound.dispatch", { type, mode });
343
+ enqueueAudio(async () => {
344
+ try {
345
+ await audioService.playWindowsSound(type);
346
+ } catch (error) {
347
+ if (mode === "sound-first" && config.enableTts) {
348
+ logger.debug("audio.sound.failed_tts_fallback", { type, mode, error });
349
+ await audioService.speakWithSapi(text);
350
+ return;
351
+ }
352
+ throw error;
353
+ }
354
+ });
355
+ }
356
+
357
+ if (shouldSpeakNow) {
358
+ logger.debug("audio.tts.dispatch", { type, mode });
359
+ enqueueAudio(async () => {
360
+ try {
361
+ await audioService.speakWithSapi(text);
362
+ } catch (error) {
363
+ if (!shouldPlaySoundNow && config.enableSound) {
364
+ logger.debug("audio.tts.failed_sound_fallback", { type, mode, error });
365
+ await audioService.playWindowsSound(type);
366
+ }
367
+ throw error;
368
+ }
369
+ });
370
+ }
371
+ };
372
+
373
+ const dispatchDesktop = (type: NotificationType, message: string, ctx: ExtensionContext): void => {
374
+ if (!config.enableDesktopNotification) {
375
+ return;
376
+ }
377
+
378
+ queueTask(
379
+ (async () => {
380
+ const result = await sendDesktopNotification({
381
+ type,
382
+ message,
383
+ timeoutSeconds: config.desktopNotificationTimeout,
384
+ debugLog: config.debugLog,
385
+ });
386
+ if (result.success) {
387
+ logger.debug("desktop.notify.sent", {
388
+ type,
389
+ platform: result.platform,
390
+ timeoutSeconds: config.desktopNotificationTimeout,
391
+ });
392
+ return;
393
+ }
394
+
395
+ logger.debug("desktop.notify.failed", {
396
+ type,
397
+ platform: result.platform,
398
+ unsupported: Boolean(result.unsupported),
399
+ error: result.error,
400
+ });
401
+ if (result.unsupported && !warnedDesktopUnsupported) {
402
+ warnedDesktopUnsupported = true;
403
+ notifyUser(ctx, result.error ?? "Desktop notifications are not supported on this platform.", "warning");
404
+ }
405
+ })(),
406
+ logError,
407
+ );
408
+ };
409
+
410
+ const triggerNotification = (
411
+ type: NotificationType,
412
+ ctx: ExtensionContext,
413
+ options: { bypassThrottle?: boolean; customMessage?: string } = {},
414
+ ): void => {
415
+ if (!config.enabled || !isNotificationEnabled(config, type)) {
416
+ return;
417
+ }
418
+ if (!options.bypassThrottle && shouldThrottle(type)) {
419
+ return;
420
+ }
421
+
422
+ if (!isWindows() && config.windowsOptimized && !warnedNonWindows) {
423
+ warnedNonWindows = true;
424
+ notifyUser(ctx, "smart-voice-notify is tuned for Windows. Using best-effort fallback on this platform.", "warning");
425
+ }
426
+
427
+ const spokenMessage = options.customMessage ?? pickRandom(MESSAGE_LIBRARY[type].initial);
428
+ logger.debug("notification.triggered", {
429
+ type,
430
+ bypassThrottle: Boolean(options.bypassThrottle),
431
+ notificationMode: config.notificationMode,
432
+ text: INLINE_NOTIFY_TEXT[type],
433
+ });
434
+ logger.debug("notification.channels", {
435
+ type,
436
+ hasUI: ctx.hasUI,
437
+ wakeMonitor: config.wakeMonitor,
438
+ idleThresholdSeconds: config.idleThresholdSeconds,
439
+ enableSound: config.enableSound,
440
+ enableTts: config.enableTts,
441
+ enableDesktopNotification: config.enableDesktopNotification,
442
+ desktopNotificationTimeout: config.desktopNotificationTimeout,
443
+ });
444
+ dispatchAudio(type, spokenMessage);
445
+ dispatchDesktop(type, options.customMessage ?? INLINE_NOTIFY_TEXT[type], ctx);
446
+ scheduleReminder(type, config.reminderDelaySeconds, 0);
447
+ };
448
+
449
+ const applySetting = (draft: VoiceNotifyConfig, id: string, value: string): void => {
450
+ switch (id) {
451
+ case "enabled":
452
+ draft.enabled = boolValue(value);
453
+ return;
454
+ case "debug":
455
+ draft.debugLog = boolValue(value);
456
+ return;
457
+ case "mode":
458
+ draft.notificationMode = normalizeMode(value);
459
+ return;
460
+ case "sound":
461
+ draft.enableSound = boolValue(value);
462
+ return;
463
+ case "tts":
464
+ draft.enableTts = boolValue(value);
465
+ return;
466
+ case "desktopNotify":
467
+ draft.enableDesktopNotification = boolValue(value);
468
+ return;
469
+ case "desktopNotifyTimeout":
470
+ draft.desktopNotificationTimeout = clampInt(Number(value), draft.desktopNotificationTimeout, 1, 60);
471
+ return;
472
+ case "wakeMonitor":
473
+ draft.wakeMonitor = boolValue(value);
474
+ return;
475
+ case "idleThresholdSeconds":
476
+ draft.idleThresholdSeconds = clampInt(Number(value), draft.idleThresholdSeconds, 5, 600);
477
+ return;
478
+ case "reminder":
479
+ draft.reminderEnabled = boolValue(value);
480
+ return;
481
+ case "delay":
482
+ draft.reminderDelaySeconds = clampInt(Number(value), draft.reminderDelaySeconds, 5, 300);
483
+ return;
484
+ case "followUp":
485
+ draft.followUpEnabled = boolValue(value);
486
+ return;
487
+ case "maxFollowUps":
488
+ draft.maxFollowUps = clampInt(Number(value), draft.maxFollowUps, 1, 10);
489
+ return;
490
+ case "idle":
491
+ draft.enableIdleNotification = boolValue(value);
492
+ return;
493
+ case "permission":
494
+ draft.enablePermissionNotification = boolValue(value);
495
+ return;
496
+ case "question":
497
+ draft.enableQuestionNotification = boolValue(value);
498
+ return;
499
+ case "error":
500
+ draft.enableErrorNotification = boolValue(value);
501
+ return;
502
+ case "suppressIdleAfterError":
503
+ draft.suppressIdleAfterError = boolValue(value);
504
+ return;
505
+ case "ttsVoice":
506
+ draft.ttsVoice = value;
507
+ return;
508
+ case "ttsRate":
509
+ draft.ttsRate = clampInt(Number(value), draft.ttsRate, -10, 10);
510
+ return;
511
+ default:
512
+ return;
513
+ }
514
+ };
515
+
516
+ const buildSettings = (draft: VoiceNotifyConfig, voices: string[]): SettingItem[] => {
517
+ const voiceValues = Array.from(new Set([...voices, draft.ttsVoice]));
518
+ const items: SettingItem[] = [
519
+ { id: "enabled", label: "Extension Enabled", currentValue: draft.enabled ? "on" : "off", values: [...BOOLEAN_VALUES] },
520
+ { id: "debug", label: "Debug Log to File", currentValue: draft.debugLog ? "on" : "off", values: [...BOOLEAN_VALUES] },
521
+ {
522
+ id: "mode",
523
+ label: "Notification Mode",
524
+ currentValue: draft.notificationMode,
525
+ values: [...NOTIFICATION_MODES],
526
+ },
527
+ { id: "sound", label: "Play Sound", currentValue: draft.enableSound ? "on" : "off", values: [...BOOLEAN_VALUES] },
528
+ { id: "tts", label: "Speak TTS", currentValue: draft.enableTts ? "on" : "off", values: [...BOOLEAN_VALUES] },
529
+ {
530
+ id: "desktopNotify",
531
+ label: "Desktop Toast Notification",
532
+ currentValue: draft.enableDesktopNotification ? "on" : "off",
533
+ values: [...BOOLEAN_VALUES],
534
+ },
535
+ {
536
+ id: "desktopNotifyTimeout",
537
+ label: "Desktop Toast Timeout (seconds)",
538
+ currentValue: String(draft.desktopNotificationTimeout),
539
+ values: [...DESKTOP_NOTIFICATION_TIMEOUT_VALUES],
540
+ },
541
+ { id: "wakeMonitor", label: "Wake Monitor", currentValue: draft.wakeMonitor ? "on" : "off", values: [...BOOLEAN_VALUES] },
542
+ {
543
+ id: "idleThresholdSeconds",
544
+ label: "Wake Idle Threshold (seconds)",
545
+ currentValue: String(draft.idleThresholdSeconds),
546
+ values: [...IDLE_THRESHOLD_VALUES],
547
+ },
548
+ { id: "reminder", label: "Reminder Enabled", currentValue: draft.reminderEnabled ? "on" : "off", values: [...BOOLEAN_VALUES] },
549
+ { id: "delay", label: "Reminder Delay (seconds)", currentValue: String(draft.reminderDelaySeconds), values: [...REMINDER_DELAY_VALUES] },
550
+ { id: "followUp", label: "Follow-up Reminders", currentValue: draft.followUpEnabled ? "on" : "off", values: [...BOOLEAN_VALUES] },
551
+ { id: "maxFollowUps", label: "Max Follow-ups", currentValue: String(draft.maxFollowUps), values: [...MAX_FOLLOW_UP_VALUES] },
552
+ { id: "idle", label: "Notify On Idle", currentValue: draft.enableIdleNotification ? "on" : "off", values: [...BOOLEAN_VALUES] },
553
+ {
554
+ id: "permission",
555
+ label: "Notify On Permission Block",
556
+ currentValue: draft.enablePermissionNotification ? "on" : "off",
557
+ values: [...BOOLEAN_VALUES],
558
+ },
559
+ { id: "error", label: "Notify On Errors", currentValue: draft.enableErrorNotification ? "on" : "off", values: [...BOOLEAN_VALUES] },
560
+ {
561
+ id: "suppressIdleAfterError",
562
+ label: "Skip Idle Notify After Errors",
563
+ currentValue: draft.suppressIdleAfterError ? "on" : "off",
564
+ values: [...BOOLEAN_VALUES],
565
+ },
566
+ { id: "ttsVoice", label: "SAPI Voice", currentValue: draft.ttsVoice, values: voiceValues },
567
+ { id: "ttsRate", label: "SAPI Rate", currentValue: String(draft.ttsRate), values: [...RATE_VALUES] },
568
+ ];
569
+
570
+ if (questionToolAvailable) {
571
+ const errorIndex = items.findIndex((item) => item.id === "error");
572
+ const insertAt = errorIndex >= 0 ? errorIndex : items.length;
573
+ items.splice(insertAt, 0, {
574
+ id: "question",
575
+ label: "Notify On Questions",
576
+ currentValue: draft.enableQuestionNotification ? "on" : "off",
577
+ values: [...BOOLEAN_VALUES],
578
+ });
579
+ }
580
+
581
+ return items;
582
+ };
583
+
584
+ const openConfigModal = async (ctx: ExtensionCommandContext): Promise<void> => {
585
+ if (!ctx.hasUI) {
586
+ pi.sendMessage({
587
+ customType: EXTENSION_ID,
588
+ content: `Configuration UI requires interactive mode.\n\n${summarizeConfig(config)}`,
589
+ display: true,
590
+ });
591
+ return;
592
+ }
593
+
594
+ let voices: string[] = [config.ttsVoice];
595
+ try {
596
+ voices = await audioService.getInstalledVoices();
597
+ } catch (error) {
598
+ notifyUser(ctx, `Could not load Windows voices: ${getErrorMessage(error)}`, "warning");
599
+ }
600
+
601
+ const draft: VoiceNotifyConfig = { ...config };
602
+ const items = buildSettings(draft, voices);
603
+ const overlayOptions = { anchor: "center" as const, width: 92, maxHeight: "85%" as const, margin: 1 };
604
+ const description = !questionToolAvailable
605
+ ? "Question notifications are hidden (no custom 'question' tool loaded)."
606
+ : "Configure Windows voice and reminder notification behavior.";
607
+
608
+ await ctx.ui.custom<void>(
609
+ (tui, theme, _keybindings, done) => {
610
+ const settingsModal = new ZellijSettingsModal(
611
+ {
612
+ title: "Voice Notify Settings",
613
+ description,
614
+ settings: items,
615
+ onChange: (id, newValue) => {
616
+ const previousConfig = config;
617
+ applySetting(draft, id, newValue);
618
+ config = normalizeConfig(draft);
619
+ if (config.debugLog && !previousConfig.debugLog) {
620
+ logger.debug("debug.enabled", { debugLogPath: DEBUG_LOG_PATH });
621
+ }
622
+ logger.debug("config.setting_updated", { id, newValue });
623
+ if (!config.enabled || !config.reminderEnabled) {
624
+ cancelAllReminders();
625
+ }
626
+ persistConfig(ctx);
627
+ updateStatus(ctx);
628
+ },
629
+ onClose: () => done(),
630
+ helpText: `Config: ${CONFIG_PATH} • Debug: ${DEBUG_LOG_PATH}`,
631
+ enableSearch: true,
632
+ },
633
+ theme,
634
+ );
635
+
636
+ const modal = new ZellijModal(
637
+ settingsModal,
638
+ {
639
+ borderStyle: "rounded",
640
+ titleBar: {
641
+ left: "Voice Notify Settings",
642
+ right: EXTENSION_ID,
643
+ },
644
+ helpUndertitle: {
645
+ text: "Esc: close | ↑↓: navigate | Space: toggle",
646
+ color: "dim",
647
+ },
648
+ overlay: overlayOptions,
649
+ },
650
+ theme,
651
+ );
652
+
653
+ return {
654
+ render(width: number): string[] {
655
+ return modal.renderModal(width).lines;
656
+ },
657
+ invalidate(): void {
658
+ modal.invalidate();
659
+ },
660
+ handleInput(data: string): void {
661
+ modal.handleInput(data);
662
+ tui.requestRender();
663
+ },
664
+ };
665
+ },
666
+ { overlay: true, overlayOptions },
667
+ );
668
+ };
669
+
670
+ const runCommand = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
671
+ const trimmed = args.trim();
672
+ logger.debug("command.invoked", { args: trimmed || "(open-config-modal)" });
673
+ if (!trimmed) {
674
+ await openConfigModal(ctx);
675
+ return;
676
+ }
677
+
678
+ const [subcommandRaw, typeRaw] = trimmed.split(/\s+/, 2);
679
+ const subcommand = subcommandRaw.toLowerCase();
680
+
681
+ if (subcommand === "status") {
682
+ const summary = `${summarizeConfig(config)}\nquestionToolAvailable=${questionToolAvailable}`;
683
+ if (ctx.hasUI) {
684
+ notifyUser(ctx, summary, "info");
685
+ } else {
686
+ pi.sendMessage({ customType: EXTENSION_ID, content: summary, display: true });
687
+ }
688
+ return;
689
+ }
690
+
691
+ if (subcommand === "reload") {
692
+ config = readConfigFromDisk();
693
+ refreshQuestionToolAvailability();
694
+ cancelAllReminders();
695
+ updateStatus(ctx);
696
+ notifyUser(ctx, "Reloaded smart voice notify config from disk.", "info");
697
+ return;
698
+ }
699
+
700
+ if (subcommand === "on" || subcommand === "off") {
701
+ config.enabled = subcommand === "on";
702
+ persistConfig(ctx);
703
+ if (!config.enabled) {
704
+ cancelAllReminders();
705
+ }
706
+ updateStatus(ctx);
707
+ notifyUser(ctx, `smart-voice-notify ${config.enabled ? "enabled" : "disabled"}.`, "info");
708
+ return;
709
+ }
710
+
711
+ if (subcommand === "test") {
712
+ const type = (typeRaw || "idle").toLowerCase() as NotificationType;
713
+ if (type !== "idle" && type !== "permission" && type !== "question" && type !== "error") {
714
+ notifyUser(ctx, "Usage: /voice-notify test [idle|permission|question|error]", "warning");
715
+ return;
716
+ }
717
+ if (type === "question" && !questionToolAvailable) {
718
+ notifyUser(ctx, "Question notifications are unavailable because no custom 'question' tool is loaded.", "warning");
719
+ return;
720
+ }
721
+ triggerNotification(type, ctx, {
722
+ bypassThrottle: true,
723
+ customMessage: `Test ${type} notification from /voice-notify test.`,
724
+ });
725
+ return;
726
+ }
727
+
728
+ notifyUser(ctx, "Usage: /voice-notify [status|reload|on|off|test <type>]", "warning");
729
+ };
730
+
731
+ pi.registerCommand("voice-notify", {
732
+ description: "Configure Windows smart voice notifications",
733
+ handler: async (args, ctx) => {
734
+ await runCommand(args, ctx);
735
+ },
736
+ });
737
+
738
+ pi.on("session_start", async (_event, ctx) => {
739
+ config = readConfigFromDisk();
740
+ refreshQuestionToolAvailability();
741
+ lastUserActivityAt = Date.now();
742
+ hadErrorInTurn = false;
743
+ warnedNonWindows = false;
744
+ warnedDesktopUnsupported = false;
745
+ processedToolCallIds.clear();
746
+ lastNotificationAt.clear();
747
+ cancelAllReminders();
748
+ updateStatus(ctx);
749
+ logger.debug("session.start", {
750
+ configPath: CONFIG_PATH,
751
+ debugLogPath: DEBUG_LOG_PATH,
752
+ notificationMode: config.notificationMode,
753
+ });
754
+ });
755
+
756
+ pi.on("session_switch", async (_event, ctx) => {
757
+ refreshQuestionToolAvailability();
758
+ lastUserActivityAt = Date.now();
759
+ hadErrorInTurn = false;
760
+ warnedDesktopUnsupported = false;
761
+ processedToolCallIds.clear();
762
+ lastNotificationAt.clear();
763
+ cancelAllReminders();
764
+ updateStatus(ctx);
765
+ logger.debug("session.switch", {});
766
+ });
767
+
768
+ pi.on("session_shutdown", async (_event, ctx) => {
769
+ logger.debug("session.shutdown", {});
770
+ cancelAllReminders();
771
+ if (ctx.hasUI) {
772
+ ctx.ui.setStatus(STATUS_KEY, undefined);
773
+ }
774
+ });
775
+
776
+ pi.on("input", async (event) => {
777
+ if (event.source !== "extension") {
778
+ lastUserActivityAt = Date.now();
779
+ cancelAllReminders();
780
+ }
781
+ });
782
+
783
+ pi.on("agent_start", async () => {
784
+ hadErrorInTurn = false;
785
+ processedToolCallIds.clear();
786
+ logger.debug("agent.start", {});
787
+ });
788
+
789
+ pi.on("tool_call", async (event, ctx) => {
790
+ if (!config.enabled || !config.enablePermissionNotification) {
791
+ return {};
792
+ }
793
+
794
+ const reason = extractToolCallBlockReason(event);
795
+ if (!reason || !isPermissionReason(reason)) {
796
+ return {};
797
+ }
798
+
799
+ if (!rememberProcessedToolCallId(event.toolCallId)) {
800
+ return {};
801
+ }
802
+
803
+ logger.debug("tool_call.permission_blocked", {
804
+ toolCallId: event.toolCallId,
805
+ toolName: event.toolName,
806
+ reason,
807
+ });
808
+ triggerNotification("permission", ctx);
809
+ return {};
810
+ });
811
+
812
+ pi.on("tool_result", async (event, ctx) => {
813
+ if (!config.enabled) {
814
+ return;
815
+ }
816
+
817
+ if (!rememberProcessedToolCallId(event.toolCallId)) {
818
+ return;
819
+ }
820
+
821
+ const toolName = typeof event.toolName === "string" ? event.toolName : "";
822
+ const text = extractTextContent(event.content);
823
+ const type = classifyToolResult(toolName, event.isError, text);
824
+ if (!type) {
825
+ return;
826
+ }
827
+ if (type === "question" && !questionToolAvailable) {
828
+ return;
829
+ }
830
+
831
+ if (event.isError) {
832
+ hadErrorInTurn = true;
833
+ }
834
+
835
+ logger.debug("tool_result.classified", {
836
+ toolCallId: event.toolCallId,
837
+ toolName,
838
+ isError: event.isError,
839
+ notificationType: type,
840
+ });
841
+ triggerNotification(type, ctx);
842
+ });
843
+
844
+ pi.on("agent_end", async (_event, ctx) => {
845
+ if (!config.enabled || !config.enableIdleNotification) {
846
+ logger.debug("agent.end.idle_skipped", {
847
+ reason: "idle_notification_disabled",
848
+ enabled: config.enabled,
849
+ enableIdleNotification: config.enableIdleNotification,
850
+ });
851
+ return;
852
+ }
853
+ if (config.suppressIdleAfterError && hadErrorInTurn) {
854
+ logger.debug("agent.end.idle_skipped", { reason: "suppressed_after_error" });
855
+ return;
856
+ }
857
+ logger.debug("agent.end.idle_trigger", {});
858
+ triggerNotification("idle", ctx);
859
+ });
860
+ }