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 +11 -0
- package/package.json +61 -61
- package/src/index.test.ts +107 -0
- package/src/index.ts +112 -0
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.
|
|
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":
|