pi-smart-voice-notify 0.2.2 → 0.2.3

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.3] - 2026-03-13
4
+
5
+ ### Added
6
+ - Integration with `pi-permission-system:permission-request` event channel for permission request notifications
7
+ - Automatic cancellation of permission reminders when approval/denial is received from the permission system
8
+ - Deduplication to prevent duplicate notifications when permission system events precede tool_call events
9
+ - New test coverage for permission system event integration
10
+
11
+ ### Changed
12
+ - Refactored to use shared `toRecord` utility from `pi-permission-system`, removing duplicate implementation
13
+
3
14
  ## [0.2.2] - 2026-03-12
4
15
 
5
16
  ### Changed
package/package.json CHANGED
@@ -1,61 +1,61 @@
1
- {
2
- "name": "pi-smart-voice-notify",
3
- "version": "0.2.2",
4
- "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "config/config.example.json",
14
- "assets",
15
- "README.md",
16
- "CHANGELOG.md",
17
- "LICENSE"
18
- ],
19
- "scripts": {
20
- "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
- "lint": "npm run build",
22
- "test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
23
- "check": "npm run build && npm run test"
24
- },
25
- "keywords": [
26
- "pi-package",
27
- "pi",
28
- "pi-extension",
29
- "voice-notify",
30
- "windows",
31
- "desktop-notification"
32
- ],
33
- "author": "MasuRii",
34
- "license": "MIT",
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/MasuRii/pi-smart-voice-notify.git"
38
- },
39
- "homepage": "https://github.com/MasuRii/pi-smart-voice-notify#readme",
40
- "bugs": {
41
- "url": "https://github.com/MasuRii/pi-smart-voice-notify/issues"
42
- },
43
- "engines": {
44
- "node": ">=24"
45
- },
46
- "publishConfig": {
47
- "access": "public"
48
- },
49
- "pi": {
50
- "extensions": [
51
- "./index.ts"
52
- ]
53
- },
54
- "peerDependencies": {
55
- "@mariozechner/pi-coding-agent": "*",
56
- "@mariozechner/pi-tui": "*"
57
- },
58
- "dependencies": {
59
- "node-notifier": "^10.0.1"
60
- }
61
- }
1
+ {
2
+ "name": "pi-smart-voice-notify",
3
+ "version": "0.2.3",
4
+ "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "assets",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
+ "lint": "npm run build",
22
+ "test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
23
+ "check": "npm run build && npm run test"
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi",
28
+ "pi-extension",
29
+ "voice-notify",
30
+ "windows",
31
+ "desktop-notification"
32
+ ],
33
+ "author": "MasuRii",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/MasuRii/pi-smart-voice-notify.git"
38
+ },
39
+ "homepage": "https://github.com/MasuRii/pi-smart-voice-notify#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/MasuRii/pi-smart-voice-notify/issues"
42
+ },
43
+ "engines": {
44
+ "node": ">=24"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "pi": {
50
+ "extensions": [
51
+ "./index.ts"
52
+ ]
53
+ },
54
+ "peerDependencies": {
55
+ "@mariozechner/pi-coding-agent": "*",
56
+ "@mariozechner/pi-tui": "*"
57
+ },
58
+ "dependencies": {
59
+ "node-notifier": "^10.0.1"
60
+ }
61
+ }
package/src/index.test.ts CHANGED
@@ -26,6 +26,9 @@ type PermissionForwardingWatcherController = ReturnType<PermissionForwardingWatc
26
26
  type ForwardedPermissionRequestEvent = Parameters<PermissionForwardingWatcherOptions["onRequest"]>[0];
27
27
  type ForwardedPermissionResolutionEvent = Parameters<PermissionForwardingWatcherOptions["onResolve"]>[0];
28
28
  type PermissionForwardingWatcherConfig = Parameters<PermissionForwardingWatcherController["start"]>[0];
29
+ type EventBusHandler = (payload: unknown) => void;
30
+
31
+ const PERMISSION_SYSTEM_EVENT_CHANNEL = "pi-permission-system:permission-request";
29
32
 
30
33
  const EMPTY_AVAILABILITY: TTSAvailability = {
31
34
  "espeak-ng": false,
@@ -35,9 +38,31 @@ const EMPTY_AVAILABILITY: TTSAvailability = {
35
38
  sapi: false,
36
39
  };
37
40
 
41
+ class FakeEventBus {
42
+ private readonly handlers = new Map<string, EventBusHandler[]>();
43
+
44
+ public on(channel: string, handler: EventBusHandler): () => void {
45
+ const existing = this.handlers.get(channel) ?? [];
46
+ existing.push(handler);
47
+ this.handlers.set(channel, existing);
48
+ return () => {
49
+ const current = this.handlers.get(channel) ?? [];
50
+ this.handlers.set(channel, current.filter((entry) => entry !== handler));
51
+ };
52
+ }
53
+
54
+ public emit(channel: string, payload: unknown): void {
55
+ for (const handler of this.handlers.get(channel) ?? []) {
56
+ handler(payload);
57
+ }
58
+ }
59
+ }
60
+
38
61
  class FakePi {
39
62
  private readonly handlers = new Map<string, EventHandler[]>();
40
63
 
64
+ public readonly events = new FakeEventBus();
65
+
41
66
  public on(eventName: string, handler: EventHandler): void {
42
67
  const existing = this.handlers.get(eventName) ?? [];
43
68
  existing.push(handler);
@@ -223,6 +248,32 @@ function permissionEvent(toolCallId: string): { block: boolean; reason: string;
223
248
  };
224
249
  }
225
250
 
251
+ function permissionSystemEvent(
252
+ state: "waiting" | "approved" | "denied",
253
+ requestId: string,
254
+ overrides: Partial<{
255
+ source: "tool_call" | "skill_input" | "skill_read";
256
+ message: string;
257
+ toolCallId: string;
258
+ toolName: string;
259
+ skillName: string;
260
+ path: string;
261
+ agentName: string | null;
262
+ }> = {},
263
+ ): Record<string, unknown> {
264
+ return {
265
+ requestId,
266
+ state,
267
+ source: overrides.source ?? "tool_call",
268
+ message: overrides.message ?? "Current agent requested tool 'write'. Allow this call?",
269
+ toolCallId: overrides.toolCallId,
270
+ toolName: overrides.toolName,
271
+ skillName: overrides.skillName,
272
+ path: overrides.path,
273
+ agentName: overrides.agentName ?? null,
274
+ };
275
+ }
276
+
226
277
  function forwardedPermissionRequest(
227
278
  requestId: string,
228
279
  requesterAgentName = "Delegate Alpha",
@@ -287,6 +338,62 @@ async function tickAndFlush(milliseconds: number): Promise<void> {
287
338
  await flushAsyncWork();
288
339
  }
289
340
 
341
+ test("permission-system waiting events trigger a permission notification and cancel on resolution", async (t) => {
342
+ disableFocusDetection(t);
343
+ useMockClock(t);
344
+
345
+ const { ctx, pi, ttsCalls } = createHarness();
346
+
347
+ await pi.emit("session_start", {}, ctx);
348
+ await flushAsyncWork();
349
+ pi.events.emit(
350
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
351
+ permissionSystemEvent("waiting", "permission-wait", {
352
+ toolCallId: "call-wait",
353
+ toolName: "write_file",
354
+ }),
355
+ );
356
+ await flushAsyncWork();
357
+
358
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
359
+
360
+ pi.events.emit(
361
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
362
+ permissionSystemEvent("approved", "permission-wait", {
363
+ toolCallId: "call-wait",
364
+ toolName: "write_file",
365
+ }),
366
+ );
367
+ await flushAsyncWork();
368
+ await tickAndFlush(1_000);
369
+
370
+ assert.equal(countReminderCalls(ttsCalls), 0);
371
+ });
372
+
373
+ test("permission-system waiting events do not duplicate a later blocked tool_call notification", async (t) => {
374
+ disableFocusDetection(t);
375
+ useMockClock(t);
376
+
377
+ const { ctx, pi, ttsCalls } = createHarness();
378
+
379
+ await pi.emit("session_start", {}, ctx);
380
+ await flushAsyncWork();
381
+ pi.events.emit(
382
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
383
+ permissionSystemEvent("waiting", "permission-dedupe", {
384
+ toolCallId: "call-dedupe",
385
+ toolName: "write_file",
386
+ }),
387
+ );
388
+ await flushAsyncWork();
389
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
390
+
391
+ await pi.emit("tool_call", permissionEvent("call-dedupe"), ctx);
392
+ await flushAsyncWork();
393
+
394
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
395
+ });
396
+
290
397
  test("tool_execution_start only cancels the resolved permission reminder flow", async (t) => {
291
398
  disableFocusDetection(t);
292
399
  useMockClock(t);
package/src/index.ts CHANGED
@@ -246,6 +246,67 @@ function forwardedPermissionReminderKey(requestId: string): ReminderKey {
246
246
  : defaultReminderKey("permission");
247
247
  }
248
248
 
249
+ const PERMISSION_SYSTEM_EVENT_CHANNEL = "pi-permission-system:permission-request";
250
+ type PermissionSystemEventState = "waiting" | "approved" | "denied";
251
+ type PermissionSystemEventSource = "tool_call" | "skill_input" | "skill_read";
252
+
253
+ interface PermissionSystemEvent {
254
+ requestId: string;
255
+ state: PermissionSystemEventState;
256
+ source: PermissionSystemEventSource;
257
+ message: string;
258
+ toolCallId?: string;
259
+ toolName?: string;
260
+ skillName?: string;
261
+ path?: string;
262
+ agentName?: string | null;
263
+ }
264
+
265
+ function normalizeOptionalString(value: unknown): string | undefined {
266
+ if (typeof value !== "string") {
267
+ return undefined;
268
+ }
269
+ const trimmed = value.trim();
270
+ return trimmed.length > 0 ? trimmed : undefined;
271
+ }
272
+
273
+ function readPermissionSystemEvent(value: unknown): PermissionSystemEvent | null {
274
+ const record = toRecord(value);
275
+ const requestId = normalizeOptionalString(record.requestId);
276
+ const state = normalizeOptionalString(record.state);
277
+ const source = normalizeOptionalString(record.source);
278
+ const message = normalizeOptionalString(record.message);
279
+ if (!requestId || !message) {
280
+ return null;
281
+ }
282
+ if (state !== "waiting" && state !== "approved" && state !== "denied") {
283
+ return null;
284
+ }
285
+ if (source !== "tool_call" && source !== "skill_input" && source !== "skill_read") {
286
+ return null;
287
+ }
288
+
289
+ return {
290
+ requestId,
291
+ state,
292
+ source,
293
+ message,
294
+ toolCallId: normalizeOptionalString(record.toolCallId),
295
+ toolName: normalizeOptionalString(record.toolName),
296
+ skillName: normalizeOptionalString(record.skillName),
297
+ path: normalizeOptionalString(record.path),
298
+ agentName: typeof record.agentName === "string" ? record.agentName : null,
299
+ };
300
+ }
301
+
302
+ function permissionSystemReminderKey(event: PermissionSystemEvent): ReminderKey {
303
+ if (event.toolCallId) {
304
+ return permissionReminderKey(event.toolCallId);
305
+ }
306
+
307
+ return `permission:request:${event.requestId}`;
308
+ }
309
+
249
310
  export default function smartVoiceNotifyExtension(
250
311
  pi: ExtensionAPI,
251
312
  dependencies: SmartVoiceNotifyDependencies = {},
@@ -1021,6 +1082,57 @@ export default function smartVoiceNotifyExtension(
1021
1082
  );
1022
1083
  };
1023
1084
 
1085
+ pi.events.on(PERMISSION_SYSTEM_EVENT_CHANNEL, (payload: unknown) => {
1086
+ const event = readPermissionSystemEvent(payload);
1087
+ if (!event) {
1088
+ return;
1089
+ }
1090
+ if (!config.enabled || !config.enablePermissionNotification) {
1091
+ return;
1092
+ }
1093
+ if (!activeSessionContext) {
1094
+ logger.debug("permission_system.notification_skipped", {
1095
+ reason: "missing_session_context",
1096
+ requestId: event.requestId,
1097
+ state: event.state,
1098
+ source: event.source,
1099
+ });
1100
+ return;
1101
+ }
1102
+
1103
+ const reminderKey = permissionSystemReminderKey(event);
1104
+ if (event.state === "waiting") {
1105
+ if (event.toolCallId) {
1106
+ pendingPermissionToolCallIds.add(event.toolCallId);
1107
+ rememberScopedToolCallId(event.toolCallId, blockedPermissionToolCallIds);
1108
+ }
1109
+ logger.debug("permission_system.wait_detected", {
1110
+ requestId: event.requestId,
1111
+ toolCallId: event.toolCallId ?? null,
1112
+ toolName: event.toolName ?? null,
1113
+ skillName: event.skillName ?? null,
1114
+ source: event.source,
1115
+ });
1116
+ triggerNotification("permission", activeSessionContext, {
1117
+ reason: event.message,
1118
+ reminderKey,
1119
+ });
1120
+ return;
1121
+ }
1122
+
1123
+ if (event.toolCallId) {
1124
+ pendingPermissionToolCallIds.delete(event.toolCallId);
1125
+ }
1126
+ cancelReminderActivityForKey(reminderKey, "permission_system_wait_resolved", {
1127
+ requestId: event.requestId,
1128
+ toolCallId: event.toolCallId ?? null,
1129
+ toolName: event.toolName ?? null,
1130
+ skillName: event.skillName ?? null,
1131
+ state: event.state,
1132
+ source: event.source,
1133
+ });
1134
+ });
1135
+
1024
1136
  const applySetting = (draft: VoiceNotifyConfig, id: string, value: string): void => {
1025
1137
  switch (id) {
1026
1138
  case "enabled":