pi-smart-voice-notify 0.2.1 → 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,22 @@
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
+
14
+ ## [0.2.2] - 2026-03-12
15
+
16
+ ### Changed
17
+ - Refactored to use shared `toRecord` utility, removing duplicate implementation
18
+ - Consolidated exports and simplified `index.ts`
19
+
3
20
  ## [0.2.0] - 2026-03-07
4
21
 
5
22
  ### Added
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 MasuRii
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MasuRii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-smart-voice-notify",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -1,3 +1,4 @@
1
+ import { normalizeFloat } from "./config-store.ts";
1
2
  import { getErrorMessage } from "./logging.ts";
2
3
 
3
4
  export const AI_EVENT_TYPES = [
@@ -134,13 +135,6 @@ function normalizePositiveInt(value: number, fallback: number, min: number, max:
134
135
  return Math.min(max, Math.max(min, Math.round(value)));
135
136
  }
136
137
 
137
- function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
138
- if (!Number.isFinite(value)) {
139
- return fallback;
140
- }
141
- return Math.min(max, Math.max(min, value));
142
- }
143
-
144
138
  function normalizeConfig(overrides: Partial<AIMessageConfig> = {}): AIMessageConfig {
145
139
  return {
146
140
  ...DEFAULT_AI_MESSAGE_CONFIG,
@@ -265,7 +265,7 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
265
265
  debugLog: false,
266
266
  };
267
267
 
268
- function toRecord(value: unknown): Record<string, unknown> {
268
+ export function toRecord(value: unknown): Record<string, unknown> {
269
269
  if (!value || typeof value !== "object" || Array.isArray(value)) {
270
270
  return {};
271
271
  }
@@ -325,6 +325,13 @@ export function clampNumber(value: unknown, fallback: number, min: number, max:
325
325
  return Math.min(max, Math.max(min, numeric));
326
326
  }
327
327
 
328
+ export function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
329
+ if (!Number.isFinite(value)) {
330
+ return fallback;
331
+ }
332
+ return Math.min(max, Math.max(min, value));
333
+ }
334
+
328
335
  function boolOrDefault(value: unknown, fallback: boolean): boolean {
329
336
  if (typeof value === "boolean") {
330
337
  return value;
@@ -1,177 +1,171 @@
1
- type NotificationType = "idle" | "permission" | "question" | "error";
2
-
3
- type LinuxUrgency = "low" | "normal" | "critical";
4
-
5
- interface DesktopNotificationSupport {
6
- supported: boolean;
7
- reason?: string;
8
- }
9
-
10
- interface DesktopNotificationRequest {
11
- type: NotificationType;
12
- message: string;
13
- timeoutSeconds: number;
14
- debugLog?: boolean;
15
- }
16
-
17
- export interface DesktopNotificationResult {
18
- success: boolean;
19
- platform: NodeJS.Platform;
20
- unsupported?: boolean;
21
- error?: string;
22
- }
23
-
24
- interface NotifierLike {
25
- notify(
26
- options: Record<string, unknown>,
27
- callback?: (error: Error | null, response?: unknown, metadata?: unknown) => void,
28
- ): void;
29
- }
30
-
31
- const TITLES: Record<NotificationType, string> = {
32
- idle: "✅ Pi - Task Complete",
33
- permission: "⚠️ Pi - Permission Required",
34
- question: " Pi - Input Needed",
35
- error: " Pi - Error",
36
- };
37
-
38
- const LINUX_URGENCY: Record<NotificationType, LinuxUrgency> = {
39
- idle: "normal",
40
- permission: "critical",
41
- question: "normal",
42
- error: "critical",
43
- };
44
-
45
- let notifierPromise: Promise<NotifierLike | null> | null = null;
46
-
47
- function getErrorMessage(error: unknown): string {
48
- if (error instanceof Error) {
49
- return error.message;
50
- }
51
- return String(error);
52
- }
53
-
54
- function clampTimeoutSeconds(value: number): number {
55
- if (!Number.isFinite(value)) {
56
- return 5;
57
- }
58
- return Math.min(60, Math.max(1, Math.trunc(value)));
59
- }
60
-
61
- export function checkDesktopNotificationSupport(platform = process.platform): DesktopNotificationSupport {
62
- switch (platform) {
63
- case "win32":
64
- case "darwin":
65
- case "linux":
66
- return { supported: true };
67
- default:
68
- return {
69
- supported: false,
70
- reason: `Desktop notifications are unsupported on platform '${platform}'.`,
71
- };
72
- }
73
- }
74
-
75
- function buildNotifierOptions(request: DesktopNotificationRequest): Record<string, unknown> {
76
- const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
77
- const baseOptions: Record<string, unknown> = {
78
- title: TITLES[request.type],
79
- message: request.message,
80
- wait: false,
81
- timeout: timeoutSeconds,
82
- };
83
-
84
- if (process.platform === "linux") {
85
- baseOptions.urgency = LINUX_URGENCY[request.type];
86
- baseOptions["app-name"] = "Pi Smart Voice Notify";
87
- }
88
-
89
- if (process.platform === "win32") {
90
- baseOptions.appID = "PiSmartVoiceNotify";
91
- }
92
-
93
- if (process.platform === "darwin") {
94
- baseOptions.subtitle = "Smart Voice Notify";
95
- }
96
-
97
- return baseOptions;
98
- }
99
-
100
- async function getNotifier(): Promise<NotifierLike | null> {
101
- if (!notifierPromise) {
102
- notifierPromise = import("node-notifier")
103
- .then((module) => {
104
- const candidate = (module.default ?? module) as { notify?: NotifierLike["notify"] };
105
- if (typeof candidate.notify !== "function") {
106
- return null;
107
- }
108
- return { notify: candidate.notify };
109
- })
110
- .catch(() => null);
111
- }
112
- return notifierPromise;
113
- }
114
-
115
- export async function sendDesktopNotification(request: DesktopNotificationRequest): Promise<DesktopNotificationResult> {
116
- const support = checkDesktopNotificationSupport();
117
- if (!support.supported) {
118
- return {
119
- success: false,
120
- platform: process.platform,
121
- unsupported: true,
122
- error: support.reason,
123
- };
124
- }
125
-
126
- const notifier = await getNotifier();
127
- if (!notifier) {
128
- return {
129
- success: false,
130
- platform: process.platform,
131
- error: "node-notifier is not available. Install it in this extension directory.",
132
- };
133
- }
134
-
135
- const notifyOptions = buildNotifierOptions(request);
136
- const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
137
- const callbackTimeoutMs = Math.min(1200, Math.max(250, timeoutSeconds * 1000 + 250));
138
-
139
- return new Promise<DesktopNotificationResult>((resolve) => {
140
- let settled = false;
141
- const settle = (result: DesktopNotificationResult): void => {
142
- if (settled) {
143
- return;
144
- }
145
- settled = true;
146
- clearTimeout(safetyTimeout);
147
- resolve(result);
148
- };
149
-
150
- const safetyTimeout = setTimeout(() => {
151
- if (request.debugLog) {
152
- // Callback can be dropped by some notifier backends; treat as queued/success.
153
- }
154
- settle({ success: true, platform: process.platform });
155
- }, callbackTimeoutMs);
156
-
157
- try {
158
- notifier.notify(notifyOptions, (error) => {
159
- if (error) {
160
- settle({
161
- success: false,
162
- platform: process.platform,
163
- error: getErrorMessage(error),
164
- });
165
- return;
166
- }
167
- settle({ success: true, platform: process.platform });
168
- });
169
- } catch (error) {
170
- settle({
171
- success: false,
172
- platform: process.platform,
173
- error: getErrorMessage(error),
174
- });
175
- }
176
- });
177
- }
1
+ import type { NotificationType } from "./types.ts";
2
+ import { getErrorMessage } from "./logging.ts";
3
+
4
+ type LinuxUrgency = "low" | "normal" | "critical";
5
+
6
+ interface DesktopNotificationSupport {
7
+ supported: boolean;
8
+ reason?: string;
9
+ }
10
+
11
+ interface DesktopNotificationRequest {
12
+ type: NotificationType;
13
+ message: string;
14
+ timeoutSeconds: number;
15
+ debugLog?: boolean;
16
+ }
17
+
18
+ export interface DesktopNotificationResult {
19
+ success: boolean;
20
+ platform: NodeJS.Platform;
21
+ unsupported?: boolean;
22
+ error?: string;
23
+ }
24
+
25
+ interface NotifierLike {
26
+ notify(
27
+ options: Record<string, unknown>,
28
+ callback?: (error: Error | null, response?: unknown, metadata?: unknown) => void,
29
+ ): void;
30
+ }
31
+
32
+ const TITLES: Record<NotificationType, string> = {
33
+ idle: " Pi - Task Complete",
34
+ permission: "⚠️ Pi - Permission Required",
35
+ question: " Pi - Input Needed",
36
+ error: "❌ Pi - Error",
37
+ };
38
+
39
+ const LINUX_URGENCY: Record<NotificationType, LinuxUrgency> = {
40
+ idle: "normal",
41
+ permission: "critical",
42
+ question: "normal",
43
+ error: "critical",
44
+ };
45
+
46
+ let notifierPromise: Promise<NotifierLike | null> | null = null;
47
+
48
+ function clampTimeoutSeconds(value: number): number {
49
+ if (!Number.isFinite(value)) {
50
+ return 5;
51
+ }
52
+ return Math.min(60, Math.max(1, Math.trunc(value)));
53
+ }
54
+
55
+ export function checkDesktopNotificationSupport(platform = process.platform): DesktopNotificationSupport {
56
+ switch (platform) {
57
+ case "win32":
58
+ case "darwin":
59
+ case "linux":
60
+ return { supported: true };
61
+ default:
62
+ return {
63
+ supported: false,
64
+ reason: `Desktop notifications are unsupported on platform '${platform}'.`,
65
+ };
66
+ }
67
+ }
68
+
69
+ function buildNotifierOptions(request: DesktopNotificationRequest): Record<string, unknown> {
70
+ const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
71
+ const baseOptions: Record<string, unknown> = {
72
+ title: TITLES[request.type],
73
+ message: request.message,
74
+ wait: false,
75
+ timeout: timeoutSeconds,
76
+ };
77
+
78
+ if (process.platform === "linux") {
79
+ baseOptions.urgency = LINUX_URGENCY[request.type];
80
+ baseOptions["app-name"] = "Pi Smart Voice Notify";
81
+ }
82
+
83
+ if (process.platform === "win32") {
84
+ baseOptions.appID = "PiSmartVoiceNotify";
85
+ }
86
+
87
+ if (process.platform === "darwin") {
88
+ baseOptions.subtitle = "Smart Voice Notify";
89
+ }
90
+
91
+ return baseOptions;
92
+ }
93
+
94
+ async function getNotifier(): Promise<NotifierLike | null> {
95
+ if (!notifierPromise) {
96
+ notifierPromise = import("node-notifier")
97
+ .then((module) => {
98
+ const candidate = (module.default ?? module) as { notify?: NotifierLike["notify"] };
99
+ if (typeof candidate.notify !== "function") {
100
+ return null;
101
+ }
102
+ return { notify: candidate.notify };
103
+ })
104
+ .catch(() => null);
105
+ }
106
+ return notifierPromise;
107
+ }
108
+
109
+ export async function sendDesktopNotification(request: DesktopNotificationRequest): Promise<DesktopNotificationResult> {
110
+ const support = checkDesktopNotificationSupport();
111
+ if (!support.supported) {
112
+ return {
113
+ success: false,
114
+ platform: process.platform,
115
+ unsupported: true,
116
+ error: support.reason,
117
+ };
118
+ }
119
+
120
+ const notifier = await getNotifier();
121
+ if (!notifier) {
122
+ return {
123
+ success: false,
124
+ platform: process.platform,
125
+ error: "node-notifier is not available. Install it in this extension directory.",
126
+ };
127
+ }
128
+
129
+ const notifyOptions = buildNotifierOptions(request);
130
+ const timeoutSeconds = clampTimeoutSeconds(request.timeoutSeconds);
131
+ const callbackTimeoutMs = Math.min(1200, Math.max(250, timeoutSeconds * 1000 + 250));
132
+
133
+ return new Promise<DesktopNotificationResult>((resolve) => {
134
+ let settled = false;
135
+ const settle = (result: DesktopNotificationResult): void => {
136
+ if (settled) {
137
+ return;
138
+ }
139
+ settled = true;
140
+ clearTimeout(safetyTimeout);
141
+ resolve(result);
142
+ };
143
+
144
+ const safetyTimeout = setTimeout(() => {
145
+ if (request.debugLog) {
146
+ // Callback can be dropped by some notifier backends; treat as queued/success.
147
+ }
148
+ settle({ success: true, platform: process.platform });
149
+ }, callbackTimeoutMs);
150
+
151
+ try {
152
+ notifier.notify(notifyOptions, (error) => {
153
+ if (error) {
154
+ settle({
155
+ success: false,
156
+ platform: process.platform,
157
+ error: getErrorMessage(error),
158
+ });
159
+ return;
160
+ }
161
+ settle({ success: true, platform: process.platform });
162
+ });
163
+ } catch (error) {
164
+ settle({
165
+ success: false,
166
+ platform: process.platform,
167
+ error: getErrorMessage(error),
168
+ });
169
+ }
170
+ });
171
+ }
@@ -1,5 +1,6 @@
1
1
  import { exec, type ExecOptionsWithStringEncoding } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { getErrorMessage } from "./logging.ts";
3
4
 
4
5
  export type LinuxSessionType = "x11" | "wayland" | "unknown";
5
6
 
@@ -100,13 +101,6 @@ function emitLog(
100
101
  console.warn("[focus-detect]", message, details);
101
102
  }
102
103
 
103
- function errorToString(error: unknown): string {
104
- if (error instanceof Error) {
105
- return error.message;
106
- }
107
- return String(error);
108
- }
109
-
110
104
  export function detectLinuxSessionType(env: NodeJS.ProcessEnv = process.env): LinuxSessionType {
111
105
  const explicit = env.XDG_SESSION_TYPE?.toLowerCase().trim();
112
106
  if (explicit === "x11" || explicit === "wayland") {
@@ -170,7 +164,7 @@ async function runCommand(
170
164
  } catch (error) {
171
165
  emitLog("error", `${label}: command failed`, options, {
172
166
  command,
173
- error: errorToString(error),
167
+ error: getErrorMessage(error),
174
168
  });
175
169
  return null;
176
170
  }
@@ -252,7 +246,7 @@ async function getFocusedWindowWaylandSway(options: FocusDetectOptions): Promise
252
246
  );
253
247
  } catch (error) {
254
248
  emitLog("error", "wayland.sway failed to parse sway tree", options, {
255
- error: errorToString(error),
249
+ error: getErrorMessage(error),
256
250
  });
257
251
  return null;
258
252
  }
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
@@ -25,6 +25,7 @@ import {
25
25
  SOUND_LOOPS,
26
26
  STATUS_KEY,
27
27
  summarizeConfig,
28
+ toRecord,
28
29
  TTS_ENGINE_VALUES,
29
30
  writeConfigToDisk,
30
31
  boolValue,
@@ -102,13 +103,6 @@ function classifyToolResult(
102
103
  return "error";
103
104
  }
104
105
 
105
- function toRecord(value: unknown): Record<string, unknown> {
106
- if (!value || typeof value !== "object" || Array.isArray(value)) {
107
- return {};
108
- }
109
- return value as Record<string, unknown>;
110
- }
111
-
112
106
  function readBlockedReason(value: unknown): string | null {
113
107
  const record = toRecord(value);
114
108
  const blockValue = record.block;
@@ -252,6 +246,67 @@ function forwardedPermissionReminderKey(requestId: string): ReminderKey {
252
246
  : defaultReminderKey("permission");
253
247
  }
254
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
+
255
310
  export default function smartVoiceNotifyExtension(
256
311
  pi: ExtensionAPI,
257
312
  dependencies: SmartVoiceNotifyDependencies = {},
@@ -1027,6 +1082,57 @@ export default function smartVoiceNotifyExtension(
1027
1082
  );
1028
1083
  };
1029
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
+
1030
1136
  const applySetting = (draft: VoiceNotifyConfig, id: string, value: string): void => {
1031
1137
  switch (id) {
1032
1138
  case "enabled":
package/src/logging.ts CHANGED
@@ -1,73 +1,73 @@
1
- import { appendFileSync } from "node:fs";
2
-
3
- export function getErrorMessage(error: unknown): string {
4
- if (error instanceof Error) {
5
- return error.message;
6
- }
7
- return String(error);
8
- }
9
-
10
- function safeJsonStringify(value: unknown): string {
11
- const seen = new WeakSet<object>();
12
- return JSON.stringify(value, (_key, currentValue) => {
13
- if (currentValue instanceof Error) {
14
- return {
15
- name: currentValue.name,
16
- message: currentValue.message,
17
- stack: currentValue.stack,
18
- };
19
- }
20
- if (typeof currentValue === "bigint") {
21
- return currentValue.toString();
22
- }
23
- if (typeof currentValue === "object" && currentValue !== null) {
24
- if (seen.has(currentValue)) {
25
- return "[Circular]";
26
- }
27
- seen.add(currentValue);
28
- }
29
- return currentValue;
30
- });
31
- }
32
-
33
- interface LoggerOptions {
34
- extensionId: string;
35
- debugLogPath: string;
36
- isDebugEnabled: () => boolean;
37
- ensureDebugDirectory: () => void;
38
- }
39
-
40
- export interface ExtensionLogger {
41
- debug: (event: string, details?: Record<string, unknown>) => void;
42
- error: (error: unknown) => void;
43
- }
44
-
45
- export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
46
- const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
47
-
48
- const debug = (event: string, details: Record<string, unknown> = {}): void => {
49
- if (!isDebugEnabled()) {
50
- return;
51
- }
52
-
53
- try {
54
- ensureDebugDirectory();
55
- const line = safeJsonStringify({
56
- timestamp: new Date().toISOString(),
57
- extension: extensionId,
58
- event,
59
- ...details,
60
- });
61
- appendFileSync(debugLogPath, `${line}\n`, "utf-8");
62
- } catch (error) {
63
- console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
64
- }
65
- };
66
-
67
- const error = (cause: unknown): void => {
68
- debug("runtime.error", { error: cause });
69
- console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
70
- };
71
-
72
- return { debug, error };
73
- }
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ export function getErrorMessage(error: unknown): string {
4
+ if (error instanceof Error) {
5
+ return error.message;
6
+ }
7
+ return String(error);
8
+ }
9
+
10
+ function safeJsonStringify(value: unknown): string {
11
+ const seen = new WeakSet<object>();
12
+ return JSON.stringify(value, (_key, currentValue) => {
13
+ if (currentValue instanceof Error) {
14
+ return {
15
+ name: currentValue.name,
16
+ message: currentValue.message,
17
+ stack: currentValue.stack,
18
+ };
19
+ }
20
+ if (typeof currentValue === "bigint") {
21
+ return currentValue.toString();
22
+ }
23
+ if (typeof currentValue === "object" && currentValue !== null) {
24
+ if (seen.has(currentValue)) {
25
+ return "[Circular]";
26
+ }
27
+ seen.add(currentValue);
28
+ }
29
+ return currentValue;
30
+ });
31
+ }
32
+
33
+ interface LoggerOptions {
34
+ extensionId: string;
35
+ debugLogPath: string;
36
+ isDebugEnabled: () => boolean;
37
+ ensureDebugDirectory: () => void;
38
+ }
39
+
40
+ export interface ExtensionLogger {
41
+ debug: (event: string, details?: Record<string, unknown>) => void;
42
+ error: (error: unknown) => void;
43
+ }
44
+
45
+ export function createExtensionLogger(options: LoggerOptions): ExtensionLogger {
46
+ const { extensionId, debugLogPath, isDebugEnabled, ensureDebugDirectory } = options;
47
+
48
+ const debug = (event: string, details: Record<string, unknown> = {}): void => {
49
+ if (!isDebugEnabled()) {
50
+ return;
51
+ }
52
+
53
+ try {
54
+ ensureDebugDirectory();
55
+ const line = safeJsonStringify({
56
+ timestamp: new Date().toISOString(),
57
+ extension: extensionId,
58
+ event,
59
+ ...details,
60
+ });
61
+ appendFileSync(debugLogPath, `${line}\n`, "utf-8");
62
+ } catch (error) {
63
+ console.error(`[${extensionId}] Failed to write debug log: ${getErrorMessage(error)}`);
64
+ }
65
+ };
66
+
67
+ const error = (cause: unknown): void => {
68
+ debug("runtime.error", { error: cause });
69
+ console.error(`[${extensionId}] ${getErrorMessage(cause)}`);
70
+ };
71
+
72
+ return { debug, error };
73
+ }
@@ -14,7 +14,7 @@ const PROJECT_MARKERS = [
14
14
  ".pi",
15
15
  ] as const;
16
16
 
17
- const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
17
+ export const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
18
18
 
19
19
  export const PROJECT_SOUND_CATEGORIES = ["notification", "alert", "success", "error", "reminder"] as const;
20
20
 
@@ -49,11 +49,11 @@ interface ProjectSoundsManifest {
49
49
  let activeProjectRoot: string | null = null;
50
50
  const projectSoundCache = new Map<string, ProjectSoundContext | null>();
51
51
 
52
- function noop(): void {
52
+ export function noop(): void {
53
53
  // no-op
54
54
  }
55
55
 
56
- async function pathExists(pathValue: string): Promise<boolean> {
56
+ export async function pathExists(pathValue: string): Promise<boolean> {
57
57
  try {
58
58
  await access(pathValue, fsConstants.F_OK);
59
59
  return true;
@@ -62,7 +62,7 @@ async function pathExists(pathValue: string): Promise<boolean> {
62
62
  }
63
63
  }
64
64
 
65
- async function isDirectory(pathValue: string): Promise<boolean> {
65
+ export async function isDirectory(pathValue: string): Promise<boolean> {
66
66
  try {
67
67
  const stats = await stat(pathValue);
68
68
  return stats.isDirectory();
@@ -71,7 +71,7 @@ async function isDirectory(pathValue: string): Promise<boolean> {
71
71
  }
72
72
  }
73
73
 
74
- async function isReadableAudioFile(pathValue: string): Promise<boolean> {
74
+ export async function isReadableAudioFile(pathValue: string): Promise<boolean> {
75
75
  if (!AUDIO_EXTENSIONS.has(extname(pathValue).toLowerCase())) {
76
76
  return false;
77
77
  }
@@ -88,7 +88,7 @@ async function isReadableAudioFile(pathValue: string): Promise<boolean> {
88
88
  }
89
89
  }
90
90
 
91
- async function listAudioFiles(directory: string): Promise<string[]> {
91
+ export async function listAudioFiles(directory: string): Promise<string[]> {
92
92
  if (!(await isDirectory(directory))) {
93
93
  return [];
94
94
  }
@@ -110,16 +110,16 @@ async function listAudioFiles(directory: string): Promise<string[]> {
110
110
  return files;
111
111
  }
112
112
 
113
- function uniquePaths(paths: string[]): string[] {
113
+ export function uniquePaths(paths: string[]): string[] {
114
114
  return [...new Set(paths.map((entry) => resolve(entry)))];
115
115
  }
116
116
 
117
- function normalizeVolume(value: number | undefined): number | undefined {
117
+ export function normalizeVolume(value: number | undefined | null): number | null {
118
118
  if (typeof value !== "number" || Number.isNaN(value)) {
119
- return undefined;
119
+ return null;
120
120
  }
121
121
  const clamped = Math.max(0, Math.min(100, Math.round(value)));
122
- return Number.isFinite(clamped) ? clamped : undefined;
122
+ return Number.isFinite(clamped) ? clamped : null;
123
123
  }
124
124
 
125
125
  async function hasProjectMarker(directory: string): Promise<boolean> {
@@ -240,7 +240,7 @@ async function resolveVolumeByFile(
240
240
  const resolved: Record<string, number> = {};
241
241
  for (const [filePath, rawVolume] of Object.entries(volumeByFile)) {
242
242
  const normalizedVolume = normalizeVolume(rawVolume);
243
- if (normalizedVolume === undefined) {
243
+ if (normalizedVolume === null) {
244
244
  continue;
245
245
  }
246
246
 
@@ -3,6 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  import { getErrorMessage } from "./logging.ts";
6
+ import { toRecord } from "./config-store.ts";
6
7
 
7
8
  export type PermissionForwardingSource = "primary" | "legacy";
8
9
  export type ForwardedPermissionResolutionReason = "request_removed" | "watch_disabled" | "watcher_stopped";
@@ -53,13 +54,6 @@ interface TrackedForwardedPermissionRequest extends ForwardedPermissionRequestEv
53
54
  lastSeenAt: number;
54
55
  }
55
56
 
56
- function toRecord(value: unknown): Record<string, unknown> {
57
- if (!value || typeof value !== "object" || Array.isArray(value)) {
58
- return {};
59
- }
60
- return value as Record<string, unknown>;
61
- }
62
-
63
57
  function normalizeRequestId(value: unknown, filePath: string): string {
64
58
  if (typeof value === "string") {
65
59
  const trimmed = value.trim();
@@ -4,7 +4,16 @@ import { basename, extname, isAbsolute, join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
6
  import { getCurrentVolume, playAudio, setVolume } from "./linux.ts";
7
- import { resolveProjectSoundContext } from "./per-project-sound.ts";
7
+ import {
8
+ AUDIO_EXTENSIONS,
9
+ isDirectory as checkIsDirectory,
10
+ isReadableAudioFile as checkIsReadableAudioFile,
11
+ listAudioFiles as listProjectAudioFiles,
12
+ noop,
13
+ normalizeVolume,
14
+ resolveProjectSoundContext,
15
+ uniquePaths,
16
+ } from "./per-project-sound.ts";
8
17
 
9
18
  export const SOUND_CATEGORIES = ["notification", "alert", "success", "error", "reminder"] as const;
10
19
 
@@ -64,7 +73,6 @@ export interface SoundThemeServiceOptions {
64
73
  debugLog?: (message: string) => void;
65
74
  }
66
75
 
67
- const SUPPORTED_EXTENSIONS = new Set([".mp3", ".wav", ".ogg", ".m4a", ".flac"]);
68
76
  const EVENT_TO_CATEGORY: Record<string, SoundCategory> = {
69
77
  notification: "notification",
70
78
  alert: "alert",
@@ -82,24 +90,7 @@ const DEFAULT_ASSETS_DIRECTORY = fileURLToPath(new URL("../assets", import.meta.
82
90
 
83
91
  type DebugLog = (message: string) => void;
84
92
 
85
- function noop(): void {
86
- // no-op
87
- }
88
-
89
- function normalizeVolume(value: number | undefined | null): number | null {
90
- if (typeof value !== "number" || Number.isNaN(value)) {
91
- return null;
92
- }
93
- const clamped = Math.max(0, Math.min(100, Math.round(value)));
94
- return Number.isFinite(clamped) ? clamped : null;
95
- }
96
-
97
- function toUniquePaths(paths: string[]): string[] {
98
- const normalized = paths.map((value) => resolve(value));
99
- return [...new Set(normalized)];
100
- }
101
-
102
- async function pathExists(pathValue: string): Promise<boolean> {
93
+ async function isReadable(pathValue: string): Promise<boolean> {
103
94
  try {
104
95
  await access(pathValue, fsConstants.R_OK);
105
96
  return true;
@@ -108,51 +99,11 @@ async function pathExists(pathValue: string): Promise<boolean> {
108
99
  }
109
100
  }
110
101
 
111
- async function isDirectory(pathValue: string): Promise<boolean> {
112
- try {
113
- const stats = await stat(pathValue);
114
- return stats.isDirectory();
115
- } catch {
116
- return false;
117
- }
118
- }
102
+ const isDirectory = checkIsDirectory;
119
103
 
120
- async function isReadableAudioFile(pathValue: string): Promise<boolean> {
121
- if (!SUPPORTED_EXTENSIONS.has(extname(pathValue).toLowerCase())) {
122
- return false;
123
- }
124
- try {
125
- const stats = await stat(pathValue);
126
- if (!stats.isFile()) {
127
- return false;
128
- }
129
- await access(pathValue, fsConstants.R_OK);
130
- return true;
131
- } catch {
132
- return false;
133
- }
134
- }
135
-
136
- async function listAudioFiles(directory: string): Promise<string[]> {
137
- if (!(await isDirectory(directory))) {
138
- return [];
139
- }
104
+ const isReadableAudioFile = checkIsReadableAudioFile;
140
105
 
141
- const entries = await readdir(directory, { withFileTypes: true });
142
- const files = entries
143
- .filter((entry) => entry.isFile())
144
- .map((entry) => join(directory, entry.name))
145
- .filter((filePath) => SUPPORTED_EXTENSIONS.has(extname(filePath).toLowerCase()))
146
- .sort((left, right) => left.localeCompare(right));
147
-
148
- const validFiles: string[] = [];
149
- for (const filePath of files) {
150
- if (await isReadableAudioFile(filePath)) {
151
- validFiles.push(filePath);
152
- }
153
- }
154
- return validFiles;
155
- }
106
+ const listAudioFiles = listProjectAudioFiles;
156
107
 
157
108
  async function resolveSoundReference(reference: string, searchDirectories: string[]): Promise<string | null> {
158
109
  const trimmed = reference.trim();
@@ -192,7 +143,7 @@ async function loadManifest(themeDirectory: string): Promise<ThemeManifest | nul
192
143
  const manifestCandidates = ["theme.json", "sound-theme.json"].map((fileName) => join(themeDirectory, fileName));
193
144
 
194
145
  for (const manifestPath of manifestCandidates) {
195
- if (!(await pathExists(manifestPath))) {
146
+ if (!(await isReadable(manifestPath))) {
196
147
  continue;
197
148
  }
198
149
  try {
@@ -213,7 +164,7 @@ async function loadConfigFromFile(configPath: string | undefined): Promise<Sound
213
164
  if (!configPath) {
214
165
  return {};
215
166
  }
216
- if (!(await pathExists(configPath))) {
167
+ if (!(await isReadable(configPath))) {
217
168
  return {};
218
169
  }
219
170
 
@@ -299,7 +250,7 @@ async function resolveThemeDirectories(config: SoundThemeConfig, assetsDirectory
299
250
  }
300
251
  directories.push(assetsDirectory);
301
252
 
302
- const uniqueDirectories = toUniquePaths(directories);
253
+ const uniqueDirectories = uniquePaths(directories);
303
254
  const existingDirectories: string[] = [];
304
255
  for (const directory of uniqueDirectories) {
305
256
  if (await isDirectory(directory)) {
@@ -414,7 +365,7 @@ export class SoundThemeService {
414
365
  const fromCategoryDirectory = await listAudioFiles(categoryDir);
415
366
  categoryCandidates.push(...fromCategoryDirectory);
416
367
 
417
- for (const extension of SUPPORTED_EXTENSIONS) {
368
+ for (const extension of AUDIO_EXTENSIONS) {
418
369
  const directFile = join(directory, `${category}${extension}`);
419
370
  if (await isReadableAudioFile(directFile)) {
420
371
  categoryCandidates.push(directFile);
@@ -422,7 +373,7 @@ export class SoundThemeService {
422
373
  }
423
374
  }
424
375
 
425
- const uniqueCategoryCandidates = toUniquePaths(categoryCandidates);
376
+ const uniqueCategoryCandidates = uniquePaths(categoryCandidates);
426
377
  if (uniqueCategoryCandidates.length > 0) {
427
378
  soundsByCategory[category] = uniqueCategoryCandidates;
428
379
  continue;
package/src/tts.ts CHANGED
@@ -5,6 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
7
  import { runAbortableCommand } from "./abortable-command.ts";
8
+ import { normalizeFloat } from "./config-store.ts";
8
9
  import { getErrorMessage } from "./logging.ts";
9
10
  import type {
10
11
  ConcreteTTSEngine,
@@ -71,13 +72,6 @@ function normalizeRate(value: number, fallback: number, min: number, max: number
71
72
  return Math.min(max, Math.max(min, Math.round(value)));
72
73
  }
73
74
 
74
- function normalizeFloat(value: number, fallback: number, min: number, max: number): number {
75
- if (!Number.isFinite(value)) {
76
- return fallback;
77
- }
78
- return Math.min(max, Math.max(min, value));
79
- }
80
-
81
75
  function mergeConfig(base: TTSConfig, overrides: Partial<TTSConfig>): TTSConfig {
82
76
  return {
83
77
  ...base,