pi-smart-voice-notify 0.5.0 → 0.5.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.2] - 2026-06-01
4
+
5
+ ### Changed
6
+ - Deferred notification service initialization until first use to reduce startup work.
7
+ - Replaced shared agent-directory lookup with a local `PI_CODING_AGENT_DIR`-aware resolver for config and permission-forwarding paths.
8
+ - Widened peer dependency ranges to `^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0`.
9
+
10
+ ### Fixed
11
+ - Avoid rewriting the config file when normalized content is unchanged.
12
+
13
+ ## [0.5.1] - 2026-05-26
14
+
15
+ ### Changed
16
+ - Suppressed error notifications when the agent has pending continuation messages.
17
+ - Widened peer dependency ranges to `^0.74.0 || ^0.75.0`.
18
+ - Aligned `@types/node` dev dependency to `25.9.1`.
19
+
3
20
  ## [0.5.0] - 2026-05-22
4
21
 
5
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-smart-voice-notify",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -61,14 +61,14 @@
61
61
  ]
62
62
  },
63
63
  "peerDependencies": {
64
- "@earendil-works/pi-coding-agent": "^0.75.4",
65
- "@earendil-works/pi-tui": "^0.75.4"
64
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0",
65
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0"
66
66
  },
67
67
  "dependencies": {
68
68
  "node-notifier": "^10.0.1",
69
69
  "undici": "^8.3.0"
70
70
  },
71
71
  "devDependencies": {
72
- "@types/node": "20.17.57"
72
+ "@types/node": "25.9.1"
73
73
  }
74
74
  }
@@ -0,0 +1,32 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ const PI_AGENT_DIR_ENV_VAR = "PI_CODING_AGENT_DIR";
5
+
6
+ interface AgentDirEnvironment {
7
+ [name: string]: string | undefined;
8
+ }
9
+
10
+ function expandHomeDirectory(configuredDir: string, homeDirectory: string): string {
11
+ if (configuredDir === "~") {
12
+ return homeDirectory;
13
+ }
14
+
15
+ if (configuredDir.startsWith("~/") || configuredDir.startsWith("~\\")) {
16
+ return join(homeDirectory, configuredDir.slice(2));
17
+ }
18
+
19
+ return configuredDir;
20
+ }
21
+
22
+ export function resolvePiAgentDir(
23
+ env: AgentDirEnvironment = process.env,
24
+ homeDirectory = homedir(),
25
+ ): string {
26
+ const configuredDir = env[PI_AGENT_DIR_ENV_VAR];
27
+ if (!configuredDir) {
28
+ return join(homeDirectory, ".pi", "agent");
29
+ }
30
+
31
+ return expandHomeDirectory(configuredDir, homeDirectory);
32
+ }
@@ -1,7 +1,7 @@
1
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
2
  import { isAbsolute, join } from "node:path";
4
3
 
4
+ import { resolvePiAgentDir } from "./agent-dir.ts";
5
5
  import type {
6
6
  ConcreteTTSEngine,
7
7
  MessageSet,
@@ -14,7 +14,7 @@ import type {
14
14
 
15
15
  export const EXTENSION_ID = "pi-smart-voice-notify";
16
16
  export const STATUS_KEY = "smart-voice-notify";
17
- export const CONFIG_DIR = join(getAgentDir(), "extensions", EXTENSION_ID);
17
+ export const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", EXTENSION_ID);
18
18
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
19
19
  export const DEBUG_DIR = join(CONFIG_DIR, "debug");
20
20
  export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_ID}.log`);
@@ -991,7 +991,10 @@ export function readConfigFromDisk(): VoiceNotifyConfig {
991
991
  const parsed = JSON.parse(raw) as unknown;
992
992
  const normalized = normalizeConfig(parsed);
993
993
  const validation = validateConfig(normalized);
994
- writeFileSync(CONFIG_PATH, `${JSON.stringify(validation.config, null, 2)}\n`, "utf-8");
994
+ const serialized = `${JSON.stringify(validation.config, null, 2)}\n`;
995
+ if (raw !== serialized) {
996
+ writeFileSync(CONFIG_PATH, serialized, "utf-8");
997
+ }
995
998
  const runtimeConfig = applyEnvironmentOverrides(validation.config);
996
999
  return validateConfig(runtimeConfig).config;
997
1000
  } catch {
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import type {
6
6
  import type { SettingItem } from "@earendil-works/pi-tui";
7
7
  import { basename } from "node:path";
8
8
 
9
- import { initializeAIMessageService, type AIMessageConfig } from "./ai-messages.ts";
9
+ import type { AIMessageConfig, AIMessageService } from "./ai-messages.ts";
10
10
  import {
11
11
  BOOLEAN_VALUES,
12
12
  clampInt,
@@ -30,25 +30,23 @@ import {
30
30
  boolValue,
31
31
  ensureDebugDirectory,
32
32
  } from "./config-store.ts";
33
- import { sendDesktopNotification } from "./desktop-notify.ts";
34
- import { clearFocusDetectCache, isTerminalFocused } from "./focus-detect.ts";
35
- import { detectLinuxSession, getIdleTime, wakeMonitor as wakeLinuxMonitor } from "./linux.ts";
33
+ import type { FocusDetectOptions } from "./focus-detect.ts";
36
34
  import { createExtensionLogger, getErrorMessage } from "./logging.ts";
37
- import { AudioNotificationService } from "./notify-audio.ts";
38
- import { PermissionForwardingWatcher } from "./permission-forwarding-watcher.ts";
39
- import { clearProjectSoundCache } from "./per-project-sound.ts";
35
+ import type { AudioNotificationService } from "./notify-audio.ts";
36
+ import type {
37
+ PermissionForwardingWatcherConfig,
38
+ PermissionForwardingWatcherOptions,
39
+ } from "./permission-forwarding-watcher.ts";
40
40
  import { ReminderPlaybackController } from "./reminder-playback.ts";
41
- import { SoundThemeService, type SoundThemeConfig } from "./sound-theme.ts";
42
- import { initializeTTSService } from "./tts.ts";
41
+ import type { SoundThemeConfig, SoundThemeService } from "./sound-theme.ts";
43
42
  import type {
44
43
  NotificationType,
45
44
  NotifyLevel,
46
45
  ReminderState,
47
46
  VoiceNotifyConfig,
48
47
  } from "./types.ts";
49
- import type { TTSConfig, TTSService } from "./types/tts.ts";
50
- import { createWebhookService, type WebhookConfig } from "./webhook.ts";
51
- import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.ts";
48
+ import type { TTSConfig, TTSService, TTSServiceOptions } from "./types/tts.ts";
49
+ import type { WebhookConfig, WebhookService } from "./webhook.ts";
52
50
 
53
51
  type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
54
52
 
@@ -167,6 +165,14 @@ function formatAgentErrorNotification(reason: string | undefined): string {
167
165
  return `❌ Agent ended with an error: ${normalizedReason.slice(0, 160)}`;
168
166
  }
169
167
 
168
+ function hasPendingAgentMessages(ctx: ExtensionContext): boolean {
169
+ try {
170
+ return ctx.hasPendingMessages();
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
170
176
  function statusLine(config: VoiceNotifyConfig): string | undefined {
171
177
  if (!config.enabled) {
172
178
  return "voice:off";
@@ -272,15 +278,21 @@ const REMINDER_EVENT_TYPE: Record<NotificationType, string> = {
272
278
  };
273
279
 
274
280
  type ReminderKey = string;
275
- type PermissionForwardingWatcherController = Pick<PermissionForwardingWatcher, "start" | "updateConfig" | "stop">;
281
+ type FocusDetector = (options?: FocusDetectOptions) => Promise<boolean>;
282
+
283
+ type PermissionForwardingWatcherController = {
284
+ start(config: PermissionForwardingWatcherConfig): void;
285
+ updateConfig(config: PermissionForwardingWatcherConfig): void;
286
+ stop(): void;
287
+ };
276
288
 
277
289
  export interface SmartVoiceNotifyDependencies {
278
290
  readConfigFromDisk?: typeof readConfigFromDisk;
279
- initializeTTSService?: (options?: Parameters<typeof initializeTTSService>[0]) => TTSService;
291
+ initializeTTSService?: (options?: TTSServiceOptions) => TTSService;
280
292
  createPermissionForwardingWatcher?: (
281
- options: ConstructorParameters<typeof PermissionForwardingWatcher>[0],
293
+ options: PermissionForwardingWatcherOptions,
282
294
  ) => PermissionForwardingWatcherController;
283
- isTerminalFocused?: typeof isTerminalFocused;
295
+ isTerminalFocused?: FocusDetector;
284
296
  }
285
297
 
286
298
  function defaultReminderKey(type: NotificationType): ReminderKey {
@@ -367,12 +379,7 @@ export default function smartVoiceNotifyExtension(
367
379
  dependencies: SmartVoiceNotifyDependencies = {},
368
380
  ): void {
369
381
  const readConfig = dependencies.readConfigFromDisk ?? readConfigFromDisk;
370
- const createTTSService = dependencies.initializeTTSService ?? initializeTTSService;
371
- const createPermissionForwardingWatcher =
372
- dependencies.createPermissionForwardingWatcher ??
373
- ((options: ConstructorParameters<typeof PermissionForwardingWatcher>[0]): PermissionForwardingWatcherController => {
374
- return new PermissionForwardingWatcher(options);
375
- });
382
+ const createInjectedTTSService = dependencies.initializeTTSService;
376
383
  let config = readConfig();
377
384
  let lastUserActivityAt = Date.now();
378
385
  let hadErrorInTurn = false;
@@ -408,13 +415,16 @@ export default function smartVoiceNotifyExtension(
408
415
  ensureDebugDirectory,
409
416
  });
410
417
 
411
- const audioService = new AudioNotificationService({
412
- execRunner: pi,
413
- getConfig: () => config,
414
- debug: logger.debug,
415
- });
418
+ let audioService: AudioNotificationService | null = null;
419
+ let ttsService: TTSService | null = null;
420
+ let aiMessageService: AIMessageService | null = null;
421
+ let webhookService: WebhookService | null = null;
422
+ let soundThemeService: SoundThemeService | null = null;
423
+ let permissionForwardingWatcher: PermissionForwardingWatcherController | null = null;
424
+ let focusModulePromise: Promise<typeof import("./focus-detect.ts")> | null = null;
425
+
416
426
  const projectName = basename(process.cwd()) || "project";
417
- const detectTerminalFocus = dependencies.isTerminalFocused ?? isTerminalFocused;
427
+ const injectedTerminalFocusDetector = dependencies.isTerminalFocused;
418
428
  const focusDetectionEnabled = envBoolean(
419
429
  process.platform === "linux" || process.platform === "win32",
420
430
  "PI_SMART_NOTIFY_FOCUS_DETECTION",
@@ -425,11 +435,6 @@ export default function smartVoiceNotifyExtension(
425
435
  0,
426
436
  envInteger(10_000, "PI_SMART_NOTIFY_AGENT_ERROR_GRACE_MS"),
427
437
  );
428
- const soundThemeService = new SoundThemeService({
429
- debugLog: (message) => {
430
- logger.debug("sound_theme.debug", { message });
431
- },
432
- });
433
438
  const buildTTSServiceConfig = (): TTSConfig => {
434
439
  return {
435
440
  enableTts: config.enableTts,
@@ -533,26 +538,96 @@ export default function smartVoiceNotifyExtension(
533
538
  customSoundDirectories: configuredCustomSoundDirectories.length > 0 ? configuredCustomSoundDirectories : undefined,
534
539
  };
535
540
  };
536
- let ttsService = createTTSService({
537
- execRunner: pi,
538
- config: buildTTSServiceConfig(),
539
- debug: logger.debug,
540
- });
541
- const aiMessageService = initializeAIMessageService({
542
- config: buildAIMessageConfig(),
543
- debugLog: (message, details = {}) => {
544
- logger.debug(`ai_messages.${message}`, details);
545
- },
546
- });
547
- const webhookService = createWebhookService(buildWebhookConfig());
548
- const linuxSession = detectLinuxSession();
549
- logger.debug("linux.session.detected", {
550
- sessionType: linuxSession.sessionType,
551
- display: linuxSession.display,
552
- waylandDisplay: linuxSession.waylandDisplay,
553
- });
541
+ const getAudioService = async (): Promise<AudioNotificationService> => {
542
+ if (!audioService) {
543
+ const { AudioNotificationService: AudioNotificationServiceCtor } = await import("./notify-audio.ts");
544
+ audioService = new AudioNotificationServiceCtor({
545
+ execRunner: pi,
546
+ getConfig: () => config,
547
+ debug: logger.debug,
548
+ });
549
+ }
550
+ return audioService;
551
+ };
552
+
553
+ const getTTSService = async (): Promise<TTSService> => {
554
+ if (!ttsService) {
555
+ const createTTSService = createInjectedTTSService
556
+ ?? (await import("./tts.ts")).initializeTTSService;
557
+ ttsService = createTTSService({
558
+ execRunner: pi,
559
+ config: buildTTSServiceConfig(),
560
+ debug: logger.debug,
561
+ });
562
+ }
563
+ return ttsService;
564
+ };
565
+
566
+ const getAIMessageService = async (): Promise<AIMessageService> => {
567
+ if (!aiMessageService) {
568
+ const { initializeAIMessageService } = await import("./ai-messages.ts");
569
+ aiMessageService = initializeAIMessageService({
570
+ config: buildAIMessageConfig(),
571
+ debugLog: (message, details = {}) => {
572
+ logger.debug(`ai_messages.${message}`, details);
573
+ },
574
+ });
575
+ }
576
+ return aiMessageService;
577
+ };
578
+
579
+ const getWebhookService = async (): Promise<WebhookService> => {
580
+ if (!webhookService) {
581
+ const { createWebhookService } = await import("./webhook.ts");
582
+ webhookService = createWebhookService(buildWebhookConfig());
583
+ }
584
+ return webhookService;
585
+ };
586
+
587
+ const getSoundThemeService = async (): Promise<SoundThemeService> => {
588
+ if (!soundThemeService) {
589
+ const { SoundThemeService: SoundThemeServiceCtor } = await import("./sound-theme.ts");
590
+ soundThemeService = new SoundThemeServiceCtor({
591
+ debugLog: (message) => {
592
+ logger.debug("sound_theme.debug", { message });
593
+ },
594
+ });
595
+ }
596
+ return soundThemeService;
597
+ };
598
+
599
+ const getTerminalFocusDetector = async (): Promise<FocusDetector> => {
600
+ if (injectedTerminalFocusDetector) {
601
+ return injectedTerminalFocusDetector;
602
+ }
603
+ focusModulePromise ??= import("./focus-detect.ts");
604
+ const module = await focusModulePromise;
605
+ return module.isTerminalFocused;
606
+ };
554
607
 
555
- const permissionForwardingWatcher = createPermissionForwardingWatcher({
608
+ const clearFocusDetectCacheIfLoaded = (): void => {
609
+ if (!focusModulePromise) {
610
+ return;
611
+ }
612
+ void focusModulePromise
613
+ .then((module) => module.clearFocusDetectCache())
614
+ .catch((error) => {
615
+ logger.debug("focus.cache_clear_failed", { error: getErrorMessage(error) });
616
+ });
617
+ };
618
+
619
+ const clearProjectSoundCacheIfLoaded = (): void => {
620
+ if (!soundThemeService) {
621
+ return;
622
+ }
623
+ void import("./per-project-sound.ts")
624
+ .then((module) => module.clearProjectSoundCache())
625
+ .catch((error) => {
626
+ logger.debug("sound_theme.cache_clear_failed", { error: getErrorMessage(error) });
627
+ });
628
+ };
629
+
630
+ const permissionForwardingWatcherOptions: PermissionForwardingWatcherOptions = {
556
631
  onRequest: (event) => {
557
632
  if (!config.enabled || !config.enablePermissionNotification || !config.enableForwardedPermissionWatcher) {
558
633
  return;
@@ -609,28 +684,52 @@ export default function smartVoiceNotifyExtension(
609
684
  debugLog: (event, details = {}) => {
610
685
  logger.debug(event, details);
611
686
  },
612
- });
687
+ };
613
688
 
614
- const syncPermissionForwardingWatcher = (): void => {
689
+ if (dependencies.createPermissionForwardingWatcher) {
690
+ permissionForwardingWatcher = dependencies.createPermissionForwardingWatcher(permissionForwardingWatcherOptions);
691
+ }
692
+
693
+ const getPermissionForwardingWatcher = async (): Promise<PermissionForwardingWatcherController> => {
694
+ if (!permissionForwardingWatcher) {
695
+ const { PermissionForwardingWatcher } = await import("./permission-forwarding-watcher.ts");
696
+ permissionForwardingWatcher = new PermissionForwardingWatcher(permissionForwardingWatcherOptions);
697
+ }
698
+ return permissionForwardingWatcher;
699
+ };
700
+
701
+ const syncPermissionForwardingWatcher = async (): Promise<void> => {
615
702
  if (!activeSessionContext) {
616
- permissionForwardingWatcher.stop();
703
+ permissionForwardingWatcher?.stop();
617
704
  return;
618
705
  }
619
- permissionForwardingWatcher.start({
706
+
707
+ const watcherConfig: PermissionForwardingWatcherConfig = {
620
708
  enabled: config.enabled && config.enablePermissionNotification && config.enableForwardedPermissionWatcher,
621
709
  watchLegacyPath: config.watchLegacyForwardedPermissionPath,
622
710
  targetSessionId: getPermissionForwardingSessionId(activeSessionContext),
623
- });
711
+ };
712
+
713
+ if (!watcherConfig.enabled && !permissionForwardingWatcher) {
714
+ return;
715
+ }
716
+
717
+ const watcher = watcherConfig.enabled
718
+ ? await getPermissionForwardingWatcher()
719
+ : permissionForwardingWatcher;
720
+ watcher?.start(watcherConfig);
624
721
  };
625
722
 
626
723
  const refreshIntegratedServiceConfig = (): void => {
627
- ttsService = createTTSService({
628
- execRunner: pi,
629
- config: buildTTSServiceConfig(),
630
- debug: logger.debug,
631
- });
632
- aiMessageService.updateConfig(buildAIMessageConfig());
633
- webhookService.updateConfig(buildWebhookConfig());
724
+ ttsService = createInjectedTTSService
725
+ ? createInjectedTTSService({
726
+ execRunner: pi,
727
+ config: buildTTSServiceConfig(),
728
+ debug: logger.debug,
729
+ })
730
+ : null;
731
+ aiMessageService?.updateConfig(buildAIMessageConfig());
732
+ webhookService?.updateConfig(buildWebhookConfig());
634
733
  };
635
734
 
636
735
  const rememberScopedToolCallId = (toolCallId: string, seenToolCallIds: Set<string>): boolean => {
@@ -789,6 +888,7 @@ export default function smartVoiceNotifyExtension(
789
888
  }
790
889
 
791
890
  try {
891
+ const detectTerminalFocus = await getTerminalFocusDetector();
792
892
  const focused = await detectTerminalFocus({
793
893
  debug: config.debugLog,
794
894
  cacheTtlMs: config.focusCacheTtlMs,
@@ -828,22 +928,25 @@ export default function smartVoiceNotifyExtension(
828
928
  }
829
929
 
830
930
  const eventType = options.isReminder ? REMINDER_EVENT_TYPE[type] : type;
831
- try {
832
- const generated = await aiMessageService.generateMessage(eventType, {
833
- projectName,
834
- time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
835
- count: options.followUpCount,
836
- reason: options.reason,
837
- });
838
- if (generated.trim().length > 0) {
839
- return generated;
931
+ if (config.aiMessages.enabled) {
932
+ try {
933
+ const service = await getAIMessageService();
934
+ const generated = await service.generateMessage(eventType, {
935
+ projectName,
936
+ time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
937
+ count: options.followUpCount,
938
+ reason: options.reason,
939
+ });
940
+ if (generated.trim().length > 0) {
941
+ return generated;
942
+ }
943
+ } catch (error) {
944
+ logger.debug("message.generate.error", {
945
+ type,
946
+ eventType,
947
+ error: getErrorMessage(error),
948
+ });
840
949
  }
841
- } catch (error) {
842
- logger.debug("message.generate.error", {
843
- type,
844
- eventType,
845
- error: getErrorMessage(error),
846
- });
847
950
  }
848
951
 
849
952
  return pickRandom(MESSAGE_LIBRARY[type][options.isReminder ? "reminder" : "initial"]);
@@ -855,6 +958,7 @@ export default function smartVoiceNotifyExtension(
855
958
  }
856
959
 
857
960
  if (process.platform === "linux") {
961
+ const { getIdleTime, wakeMonitor: wakeLinuxMonitor } = await import("./linux.ts");
858
962
  const idleSeconds = await getIdleTime({
859
963
  debugLog: (message) => {
860
964
  logger.debug("linux.idle", { message });
@@ -876,7 +980,8 @@ export default function smartVoiceNotifyExtension(
876
980
  return;
877
981
  }
878
982
 
879
- await audioService.wakeMonitor();
983
+ const service = await getAudioService();
984
+ await service.wakeMonitor();
880
985
  };
881
986
 
882
987
  const playNotificationSound = async (type: NotificationType): Promise<boolean> => {
@@ -886,7 +991,8 @@ export default function smartVoiceNotifyExtension(
886
991
 
887
992
  if (process.platform === "linux") {
888
993
  try {
889
- const played = await soundThemeService.playEventSound(type, buildSoundThemeConfig(), SOUND_LOOPS[type]);
994
+ const service = await getSoundThemeService();
995
+ const played = await service.playEventSound(type, buildSoundThemeConfig(), SOUND_LOOPS[type]);
890
996
  if (played) {
891
997
  return true;
892
998
  }
@@ -899,7 +1005,8 @@ export default function smartVoiceNotifyExtension(
899
1005
  }
900
1006
 
901
1007
  try {
902
- await audioService.playWindowsSound(type);
1008
+ const service = await getAudioService();
1009
+ await service.playWindowsSound(type);
903
1010
  return isWindows();
904
1011
  } catch (error) {
905
1012
  logger.debug("sound.play.legacy_failed", {
@@ -915,7 +1022,8 @@ export default function smartVoiceNotifyExtension(
915
1022
  return false;
916
1023
  }
917
1024
 
918
- const spoken = await ttsService.speak(message, config.ttsEngine, {
1025
+ const service = await getTTSService();
1026
+ const spoken = await service.speak(message, config.ttsEngine, {
919
1027
  signal,
920
1028
  sapiVoice: config.sapiVoice,
921
1029
  sapiRate: config.sapiRate,
@@ -926,7 +1034,8 @@ export default function smartVoiceNotifyExtension(
926
1034
 
927
1035
  if (isWindows()) {
928
1036
  try {
929
- await audioService.speakWithSapi(message, signal);
1037
+ const audio = await getAudioService();
1038
+ await audio.speakWithSapi(message, signal);
930
1039
  return !signal?.aborted;
931
1040
  } catch (error) {
932
1041
  logger.debug("tts.sapi_fallback_failed", {
@@ -944,6 +1053,7 @@ export default function smartVoiceNotifyExtension(
944
1053
  }
945
1054
 
946
1055
  try {
1056
+ const { sendDesktopNotification } = await import("./desktop-notify.ts");
947
1057
  const result = await sendDesktopNotification({
948
1058
  type,
949
1059
  message,
@@ -977,9 +1087,14 @@ export default function smartVoiceNotifyExtension(
977
1087
  }
978
1088
  };
979
1089
 
980
- const dispatchWebhook = (type: NotificationType, message: string): void => {
1090
+ const dispatchWebhook = async (type: NotificationType, message: string): Promise<void> => {
1091
+ if (!config.webhook.enabled) {
1092
+ return;
1093
+ }
1094
+
981
1095
  try {
982
- const dispatchResult = webhookService.dispatch({
1096
+ const service = await getWebhookService();
1097
+ const dispatchResult = service.dispatch({
983
1098
  type,
984
1099
  title: `Pi Notification - ${type}`,
985
1100
  message,
@@ -1286,7 +1401,7 @@ export default function smartVoiceNotifyExtension(
1286
1401
  }
1287
1402
 
1288
1403
  if (!shutdownRequested) {
1289
- dispatchWebhook(type, spokenMessage);
1404
+ await dispatchWebhook(type, spokenMessage);
1290
1405
  }
1291
1406
  });
1292
1407
  if (!shutdownRequested && options.scheduleReminder !== false) {
@@ -1312,6 +1427,14 @@ export default function smartVoiceNotifyExtension(
1312
1427
  if (!config.enabled || !config.enableErrorNotification) {
1313
1428
  return;
1314
1429
  }
1430
+ if (hasPendingAgentMessages(ctx)) {
1431
+ logger.debug("agent.error_notification.skipped", {
1432
+ reason: "pending_messages",
1433
+ errorReason: outcome.reason,
1434
+ stage: "schedule",
1435
+ });
1436
+ return;
1437
+ }
1315
1438
 
1316
1439
  const timeoutId = setTimeout(() => {
1317
1440
  if (pendingAgentErrorNotification !== timeoutId) {
@@ -1326,6 +1449,15 @@ export default function smartVoiceNotifyExtension(
1326
1449
  return;
1327
1450
  }
1328
1451
 
1452
+ if (hasPendingAgentMessages(ctx)) {
1453
+ logger.debug("agent.error_notification.skipped", {
1454
+ reason: "pending_messages",
1455
+ errorReason: outcome.reason,
1456
+ stage: "fire",
1457
+ });
1458
+ return;
1459
+ }
1460
+
1329
1461
  logger.debug("agent.error_notification.fired", {
1330
1462
  reason: outcome.reason,
1331
1463
  graceMs: agentErrorNotificationGraceMs,
@@ -1594,6 +1726,7 @@ export default function smartVoiceNotifyExtension(
1594
1726
  const overlayOptions = { anchor: "center" as const, width: 92, maxHeight: "85%" as const, margin: 1 };
1595
1727
  const advancedConfigPath = CONFIG_PATH;
1596
1728
  const description = `Recommended settings only.\nFor advanced settings, manually edit: ${advancedConfigPath}`;
1729
+ const { ZellijModal, ZellijSettingsModal } = await import("./zellij-modal.ts");
1597
1730
 
1598
1731
  await ctx.ui.custom<void>(
1599
1732
  (tui, theme, _keybindings, done) => {
@@ -1607,7 +1740,7 @@ export default function smartVoiceNotifyExtension(
1607
1740
  applySetting(draft, id, newValue);
1608
1741
  config = normalizeConfig(draft);
1609
1742
  refreshIntegratedServiceConfig();
1610
- syncPermissionForwardingWatcher();
1743
+ void syncPermissionForwardingWatcher();
1611
1744
  if (config.debugLog && !previousConfig.debugLog) {
1612
1745
  logger.debug("debug.enabled", { debugLogPath: DEBUG_LOG_PATH });
1613
1746
  }
@@ -1683,7 +1816,7 @@ export default function smartVoiceNotifyExtension(
1683
1816
  if (subcommand === "reload") {
1684
1817
  config = readConfig();
1685
1818
  refreshIntegratedServiceConfig();
1686
- syncPermissionForwardingWatcher();
1819
+ await syncPermissionForwardingWatcher();
1687
1820
  refreshQuestionToolAvailability();
1688
1821
  cancelReminderActivity("command_reload");
1689
1822
  updateStatus(ctx);
@@ -1694,7 +1827,7 @@ export default function smartVoiceNotifyExtension(
1694
1827
  if (subcommand === "on" || subcommand === "off") {
1695
1828
  config.enabled = subcommand === "on";
1696
1829
  refreshIntegratedServiceConfig();
1697
- syncPermissionForwardingWatcher();
1830
+ await syncPermissionForwardingWatcher();
1698
1831
  persistConfig(ctx);
1699
1832
  if (!config.enabled) {
1700
1833
  cancelReminderActivity("command_disabled");
@@ -1734,9 +1867,9 @@ export default function smartVoiceNotifyExtension(
1734
1867
  pi.on("resources_discover", (event, _ctx) => {
1735
1868
  if (event.reason === "reload") {
1736
1869
  // Clear caches on reload
1737
- clearFocusDetectCache();
1738
- clearProjectSoundCache();
1739
- aiMessageService.clearCache();
1870
+ clearFocusDetectCacheIfLoaded();
1871
+ clearProjectSoundCacheIfLoaded();
1872
+ aiMessageService?.clearCache();
1740
1873
  }
1741
1874
  });
1742
1875
 
@@ -1750,14 +1883,14 @@ export default function smartVoiceNotifyExtension(
1750
1883
  config = readConfig();
1751
1884
  refreshIntegratedServiceConfig();
1752
1885
  activeSessionContext = ctx;
1753
- syncPermissionForwardingWatcher();
1886
+ await syncPermissionForwardingWatcher();
1754
1887
  refreshQuestionToolAvailability();
1755
- clearFocusDetectCache();
1756
- clearProjectSoundCache();
1888
+ clearFocusDetectCacheIfLoaded();
1889
+ clearProjectSoundCacheIfLoaded();
1757
1890
 
1758
1891
  // Clear AI message cache on reload to pick up config changes
1759
1892
  if (reason === "reload") {
1760
- aiMessageService.clearCache();
1893
+ aiMessageService?.clearCache();
1761
1894
  }
1762
1895
  lastUserActivityAt = Date.now();
1763
1896
  hadErrorInTurn = false;
@@ -1804,17 +1937,17 @@ export default function smartVoiceNotifyExtension(
1804
1937
  shutdownPromise = (async () => {
1805
1938
  logger.debug("session.shutdown", {});
1806
1939
  activeSessionContext = null;
1807
- permissionForwardingWatcher.stop();
1940
+ permissionForwardingWatcher?.stop();
1808
1941
  pendingPermissionToolCallIds.clear();
1809
1942
  processedToolResultToolCallIds.clear();
1810
1943
  resetPermissionBatch("session_shutdown");
1811
1944
  cancelReminderActivity("session_shutdown");
1812
- clearFocusDetectCache();
1813
- clearProjectSoundCache();
1945
+ clearFocusDetectCacheIfLoaded();
1946
+ clearProjectSoundCacheIfLoaded();
1814
1947
  try {
1815
1948
  // Forward abort signal to flush if available (added in pi-coding-agent 0.67.x)
1816
1949
  const abortSignal = 'signal' in ctx ? (ctx as { signal?: AbortSignal }).signal : undefined;
1817
- await webhookService.flush(abortSignal);
1950
+ await webhookService?.flush(abortSignal);
1818
1951
  } catch (error) {
1819
1952
  const message = `Failed to flush webhook queue during shutdown: ${getErrorMessage(error)}`;
1820
1953
  logger.error(new Error(message));
@@ -1,14 +1,14 @@
1
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
1
  import { existsSync, readdirSync, readFileSync, watch, type FSWatcher } from "node:fs";
3
2
  import { basename, join } from "node:path";
4
3
 
4
+ import { resolvePiAgentDir } from "./agent-dir.ts";
5
5
  import { toRecord } from "./config-store.ts";
6
6
  import { getErrorMessage } from "./logging.ts";
7
7
 
8
8
  export type PermissionForwardingSource = "primary" | "legacy";
9
9
  export type ForwardedPermissionResolutionReason = "request_removed" | "watch_disabled" | "watcher_stopped";
10
10
 
11
- const AGENT_DIR = getAgentDir();
11
+ const AGENT_DIR = resolvePiAgentDir();
12
12
  const PERMISSION_FORWARDING_ROOT_DIR = join(AGENT_DIR, "sessions", "permission-forwarding");
13
13
  const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
14
14
  const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
@@ -60,7 +60,7 @@ export interface PermissionForwardingWatcherConfig {
60
60
  targetSessionId?: string | null;
61
61
  }
62
62
 
63
- interface PermissionForwardingWatcherOptions {
63
+ export interface PermissionForwardingWatcherOptions {
64
64
  onRequest: (event: ForwardedPermissionRequestEvent) => void;
65
65
  onResolve: (event: ForwardedPermissionResolutionEvent) => void;
66
66
  debugLog: (event: string, details?: Record<string, unknown>) => void;