pi-smart-voice-notify 0.5.2 → 0.5.4

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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.5.4] - 2026-06-22
6
+
7
+ ### Fixed
8
+ - Added a command allowlist to `runAbortableCommand` so non-allowlisted executables are rejected before spawning, preventing arbitrary command injection.
9
+ - Replaced dynamic string-based command spawning in Linux helpers with a typed `LinuxCommandName` union and dedicated `spawnLinuxCommand` switch.
10
+
11
+ ### Changed
12
+ - Added `postinstall` hook that runs `patch-vulnerable-deps.mjs` when installed under `.pi/agent/extensions/`.
13
+ - Pinned `protobufjs` 7.6.3, `ws` 8.21.0, and `uuid` 11.1.1 via npm `overrides` to resolve known vulnerabilities.
14
+ - Added `@earendil-works/pi-coding-agent` as a devDependency for type-checking.
15
+ - Updated README badge styling to for-the-badge, added platform badge and ko-fi support button.
16
+ - Added `config-store-env-override` test to the test script.
17
+
18
+ ## [0.5.3] - 2026-06-16
19
+
20
+ ### Fixed
21
+ - Filtered blank template entries so whitespace-only overrides no longer produce empty notification messages.
22
+ - Added a final fallback (`"Notification: Please check the terminal."`) when all template expansions and defaults yield empty output.
23
+
3
24
  ## [0.5.2] - 2026-06-01
4
25
 
5
26
  ### Changed
package/README.md CHANGED
@@ -1,13 +1,18 @@
1
- # 🔔 pi-smart-voice-notify
1
+ <div align="center">
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/pi-smart-voice-notify?style=flat-square)](https://www.npmjs.com/package/pi-smart-voice-notify) [![License](https://img.shields.io/github/license/MasuRii/pi-smart-voice-notify?style=flat-square)](LICENSE)
3
+ # pi-smart-voice-notify
4
4
 
5
- Windows-optimized smart notification extension for the Pi coding agent.
5
+ [![npm version](https://img.shields.io/npm/v/pi-smart-voice-notify?style=for-the-badge)](https://www.npmjs.com/package/pi-smart-voice-notify)
6
+ [![License](https://img.shields.io/github/license/MasuRii/pi-smart-voice-notify?style=for-the-badge)](LICENSE)
7
+ [![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge)]()
6
8
 
7
- **pi-smart-voice-notify** monitors Pi session and tool events to alert you via **multi-engine TTS**, **sound playback**, **desktop toast notifications**, and optional **webhook/AI-assisted messaging** when the agent requires your attention.
9
+ [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y01PSSVR)
8
10
 
11
+ Windows-optimized smart notification extension for the Pi coding agent.
12
+ **pi-smart-voice-notify** monitors Pi session and tool events to alert you via **multi-engine TTS**, **sound playback**, **desktop toast notifications**, and optional **webhook/AI-assisted messaging** when the agent requires your attention.
9
13
  <img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/c215a7fe-31b4-4bc4-a89e-992a4847819d" />
10
14
 
15
+ </div>
11
16
 
12
17
  ## Features
13
18
 
@@ -99,6 +104,7 @@ Configuration is stored at:
99
104
  ```text
100
105
  Default global path: ~/.pi/agent/extensions/pi-smart-voice-notify/config.json
101
106
  Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-smart-voice-notify/config.json when PI_CODING_AGENT_DIR is set
107
+ Local config path: <repo>/.pi/extensions/pi-smart-voice-notify/config.json
102
108
  ```
103
109
 
104
110
  A starter template is provided in `config/config.example.json`. On startup, the extension creates `config.json` with defaults if missing.
@@ -302,4 +308,4 @@ npm run check # build + test
302
308
 
303
309
  ## License
304
310
 
305
- MIT
311
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-smart-voice-notify",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -19,8 +19,9 @@
19
19
  "scripts": {
20
20
  "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
21
  "lint": "npm run build",
22
- "test": "node --experimental-strip-types --test test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
23
- "check": "npm run build && npm run test"
22
+ "test": "node --experimental-strip-types --test test/config-store.test.ts test/config-store-no-global.test.ts test/config-store-env-override.test.ts test/abortable-command.test.ts test/reminder-playback.test.ts test/permission-forwarding-watcher.test.ts test/sound-theme.test.ts test/webhook.test.ts test/linux.test.ts test/index.test.ts test/tts.test.ts",
23
+ "check": "npm run build && npm run test",
24
+ "postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
24
25
  },
25
26
  "keywords": [
26
27
  "pi-package",
@@ -61,14 +62,20 @@
61
62
  ]
62
63
  },
63
64
  "peerDependencies": {
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"
65
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0",
66
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
66
67
  },
67
68
  "dependencies": {
68
69
  "node-notifier": "^10.0.1",
69
- "undici": "^8.3.0"
70
+ "undici": "^8.5.0"
70
71
  },
71
72
  "devDependencies": {
72
- "@types/node": "25.9.1"
73
+ "@types/node": "25.9.3",
74
+ "@earendil-works/pi-coding-agent": "^0.79.7"
75
+ },
76
+ "overrides": {
77
+ "protobufjs": "7.6.3",
78
+ "ws": "8.21.0",
79
+ "uuid": "11.1.1"
73
80
  }
74
81
  }
@@ -1,4 +1,31 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
3
+
4
+ const ALLOWED_ABORTABLE_COMMANDS = new Set([
5
+ "aplay",
6
+ "edge-tts",
7
+ "espeak-ng",
8
+ "ffplay",
9
+ "gdbus",
10
+ "paplay",
11
+ "powershell",
12
+ "powershell.exe",
13
+ "swaymsg",
14
+ "where",
15
+ "which",
16
+ "xdotool",
17
+ "xprop",
18
+ ]);
19
+
20
+ function normalizeCommandName(command: string): string {
21
+ return basename(command).trim().toLowerCase();
22
+ }
23
+
24
+ function isAllowedAbortableCommand(command: string): boolean {
25
+ const normalized = normalizeCommandName(command);
26
+ const nodeExecutable = normalizeCommandName(process.execPath);
27
+ return normalized === nodeExecutable || ALLOWED_ABORTABLE_COMMANDS.has(normalized);
28
+ }
2
29
 
3
30
  export interface AbortableCommandOptions {
4
31
  timeoutMs?: number;
@@ -55,6 +82,9 @@ export async function runAbortableCommand(
55
82
  if (!Array.isArray(args)) {
56
83
  throw new Error("runAbortableCommand: args must be an array of strings");
57
84
  }
85
+ if (!isAllowedAbortableCommand(normalizedCommand)) {
86
+ throw new Error(`runAbortableCommand: command is not allowlisted: ${normalizedCommand}`);
87
+ }
58
88
 
59
89
  const commandLabel = buildCommandString(normalizedCommand, args);
60
90
  if (options.signal?.aborted) {
@@ -69,7 +99,7 @@ export async function runAbortableCommand(
69
99
  }
70
100
 
71
101
  return await new Promise<AbortableCommandResult>((resolve) => {
72
- const child = spawn(normalizedCommand, [...args], {
102
+ const child = spawn(normalizedCommand, [...args], { // nosemgrep: javascript.lang.security.detect-child-process.detect-child-process -- normalizedCommand is checked against ALLOWED_ABORTABLE_COMMANDS (plus the current Node executable for tests) before spawn; args are passed as an array with shell disabled.
73
103
  env: options.env ?? process.env,
74
104
  cwd: options.cwd,
75
105
  });
package/src/agent-dir.ts CHANGED
@@ -1,32 +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
+ 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
+ }
@@ -237,6 +237,10 @@ function sanitizeMessage(message: string, maxLength = DEFAULT_MAX_MESSAGE_LENGTH
237
237
  return `${clipped.slice(0, safeBoundary).trimEnd()}...`;
238
238
  }
239
239
 
240
+ function getUsableTemplates(templates: string[]): string[] {
241
+ return templates.filter((template) => template.trim().length > 0);
242
+ }
243
+
240
244
  function pickTemplate(templates: string[]): string {
241
245
  const index = Math.floor(Math.random() * templates.length);
242
246
  return templates[index] ?? templates[0] ?? "Notification";
@@ -252,8 +256,11 @@ function getTemplatesForEvent(eventType: string, config: AIMessageConfig): strin
252
256
  }
253
257
 
254
258
  const overrideTemplates = config.templates[eventType];
255
- if (Array.isArray(overrideTemplates) && overrideTemplates.length > 0) {
256
- return overrideTemplates;
259
+ if (Array.isArray(overrideTemplates)) {
260
+ const usableTemplates = getUsableTemplates(overrideTemplates);
261
+ if (usableTemplates.length > 0) {
262
+ return usableTemplates;
263
+ }
257
264
  }
258
265
  return DEFAULT_TEMPLATES[eventType];
259
266
  }
@@ -317,8 +324,20 @@ export class AIMessageService {
317
324
  const templates = getTemplatesForEvent(eventType, this.config);
318
325
  const template = pickTemplate(templates);
319
326
  const variables = buildTemplateVariables(eventType, context);
320
- const message = applyTemplateVariables(template, variables);
321
- return sanitizeMessage(message);
327
+ const message = sanitizeMessage(applyTemplateVariables(template, variables));
328
+ if (message.length > 0) {
329
+ return message;
330
+ }
331
+
332
+ if (isCoreEventType(eventType)) {
333
+ const fallbackTemplate = pickTemplate(DEFAULT_TEMPLATES[eventType]);
334
+ const fallbackMessage = sanitizeMessage(applyTemplateVariables(fallbackTemplate, variables));
335
+ if (fallbackMessage.length > 0) {
336
+ return fallbackMessage;
337
+ }
338
+ }
339
+
340
+ return "Notification: Please check the terminal.";
322
341
  }
323
342
 
324
343
  public async generateAIMessage(eventType: string, context: AIMessageContext = {}): Promise<string | null> {
@@ -16,6 +16,11 @@ export const EXTENSION_ID = "pi-smart-voice-notify";
16
16
  export const STATUS_KEY = "smart-voice-notify";
17
17
  export const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", EXTENSION_ID);
18
18
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
19
+
20
+ /** Project-local override config: <projectRoot>/.pi/extensions/<id>/config.json (read-only). */
21
+ export function resolveProjectConfigPath(projectRoot: string): string {
22
+ return join(projectRoot, ".pi", "extensions", EXTENSION_ID, "config.json");
23
+ }
19
24
  export const DEBUG_DIR = join(CONFIG_DIR, "debug");
20
25
  export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_ID}.log`);
21
26
 
@@ -979,28 +984,74 @@ export function ensureDebugDirectory(): void {
979
984
  }
980
985
  }
981
986
 
982
- export function readConfigFromDisk(): VoiceNotifyConfig {
987
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
988
+ return typeof value === "object" && value !== null && !Array.isArray(value);
989
+ }
990
+
991
+ /** Deep-merge `override` onto `base`. Nested objects merge; arrays and scalars replace. */
992
+ export function deepMergeConfigRecords(
993
+ base: Record<string, unknown>,
994
+ override: Record<string, unknown>,
995
+ ): Record<string, unknown> {
996
+ const result: Record<string, unknown> = { ...base };
997
+ for (const [key, value] of Object.entries(override)) {
998
+ const existing = result[key];
999
+ result[key] = isPlainObject(existing) && isPlainObject(value)
1000
+ ? deepMergeConfigRecords(existing, value)
1001
+ : value;
1002
+ }
1003
+ return result;
1004
+ }
1005
+
1006
+ function readConfigRecord(path: string): Record<string, unknown> | null {
1007
+ if (!existsSync(path)) {
1008
+ return null;
1009
+ }
1010
+ try {
1011
+ return toRecord(JSON.parse(readFileSync(path, "utf-8")) as unknown);
1012
+ } catch {
1013
+ return null;
1014
+ }
1015
+ }
1016
+
1017
+ /** Load and self-heal the global config file, returning its raw record. */
1018
+ function loadGlobalConfigRecord(): Record<string, unknown> {
983
1019
  ensureConfigDirectory();
1020
+ const defaultsSerialized = `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`;
984
1021
  if (!existsSync(CONFIG_PATH)) {
985
- writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
986
- return applyEnvironmentOverrides({ ...DEFAULT_CONFIG, webhook: { ...DEFAULT_CONFIG.webhook, events: [...DEFAULT_CONFIG.webhook.events] } });
1022
+ writeFileSync(CONFIG_PATH, defaultsSerialized, "utf-8");
1023
+ return toRecord(JSON.parse(defaultsSerialized));
987
1024
  }
988
1025
 
989
1026
  try {
990
1027
  const raw = readFileSync(CONFIG_PATH, "utf-8");
991
1028
  const parsed = JSON.parse(raw) as unknown;
992
- const normalized = normalizeConfig(parsed);
993
- const validation = validateConfig(normalized);
994
- const serialized = `${JSON.stringify(validation.config, null, 2)}\n`;
1029
+ const serialized = `${JSON.stringify(validateConfig(normalizeConfig(parsed)).config, null, 2)}\n`;
995
1030
  if (raw !== serialized) {
996
1031
  writeFileSync(CONFIG_PATH, serialized, "utf-8");
997
1032
  }
998
- const runtimeConfig = applyEnvironmentOverrides(validation.config);
999
- return validateConfig(runtimeConfig).config;
1033
+ return toRecord(parsed);
1000
1034
  } catch {
1001
- writeFileSync(CONFIG_PATH, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
1002
- return applyEnvironmentOverrides({ ...DEFAULT_CONFIG, webhook: { ...DEFAULT_CONFIG.webhook, events: [...DEFAULT_CONFIG.webhook.events] } });
1035
+ writeFileSync(CONFIG_PATH, defaultsSerialized, "utf-8");
1036
+ return toRecord(JSON.parse(defaultsSerialized));
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * Read the effective config. When `projectRoot` is given and a project config
1042
+ * exists, it deep-merges over the global config; environment variables still
1043
+ * override both. The project file is read-only — only the global file is healed.
1044
+ */
1045
+ export function readConfigFromDisk(projectRoot?: string): VoiceNotifyConfig {
1046
+ let record = loadGlobalConfigRecord();
1047
+ if (projectRoot) {
1048
+ const projectRecord = readConfigRecord(resolveProjectConfigPath(projectRoot));
1049
+ if (projectRecord) {
1050
+ record = deepMergeConfigRecords(record, projectRecord);
1051
+ }
1003
1052
  }
1053
+ const runtimeConfig = applyEnvironmentOverrides(validateConfig(normalizeConfig(record)).config);
1054
+ return validateConfig(runtimeConfig).config;
1004
1055
  }
1005
1056
 
1006
1057
  export function writeConfigToDisk(config: VoiceNotifyConfig): void {
package/src/index.ts CHANGED
@@ -1814,7 +1814,7 @@ export default function smartVoiceNotifyExtension(
1814
1814
  }
1815
1815
 
1816
1816
  if (subcommand === "reload") {
1817
- config = readConfig();
1817
+ config = readConfig(ctx.cwd);
1818
1818
  refreshIntegratedServiceConfig();
1819
1819
  await syncPermissionForwardingWatcher();
1820
1820
  refreshQuestionToolAvailability();
@@ -1880,7 +1880,7 @@ export default function smartVoiceNotifyExtension(
1880
1880
  shutdownRequested = false;
1881
1881
  shutdownPromise = null;
1882
1882
  cancelPendingAgentErrorNotification("session_start");
1883
- config = readConfig();
1883
+ config = readConfig(ctx.cwd);
1884
1884
  refreshIntegratedServiceConfig();
1885
1885
  activeSessionContext = ctx;
1886
1886
  await syncPermissionForwardingWatcher();
package/src/linux.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  const DEFAULT_TIMEOUT_MS = 8_000;
12
12
  const DEFAULT_AUDIO_TIMEOUT_MS = 20_000;
13
13
  type DebugLog = (message: string) => void;
14
+ type LinuxCommandName = "xset" | "gdbus" | "pactl" | "amixer" | "paplay" | "aplay" | "xprintidle";
14
15
 
15
16
  function createDebugLog(options?: LinuxUtilsOptions): DebugLog {
16
17
  return options?.debugLog ?? (() => {});
@@ -23,13 +24,32 @@ function buildCommandString(command: string, args: string[]): string {
23
24
  return `${command} ${args.join(" ")}`;
24
25
  }
25
26
 
27
+ function spawnLinuxCommand(command: LinuxCommandName, args: string[]) {
28
+ switch (command) {
29
+ case "xset":
30
+ return spawn("xset", args, { env: process.env });
31
+ case "gdbus":
32
+ return spawn("gdbus", args, { env: process.env });
33
+ case "pactl":
34
+ return spawn("pactl", args, { env: process.env });
35
+ case "amixer":
36
+ return spawn("amixer", args, { env: process.env });
37
+ case "paplay":
38
+ return spawn("paplay", args, { env: process.env });
39
+ case "aplay":
40
+ return spawn("aplay", args, { env: process.env });
41
+ case "xprintidle":
42
+ return spawn("xprintidle", args, { env: process.env });
43
+ }
44
+ }
45
+
26
46
  async function runSpawnCommand(
27
- command: string,
47
+ command: LinuxCommandName,
28
48
  args: string[],
29
49
  timeoutMs = DEFAULT_TIMEOUT_MS,
30
50
  ): Promise<LinuxCommandResult> {
31
51
  return await new Promise<LinuxCommandResult>((resolve) => {
32
- const child = spawn(command, args, { env: process.env });
52
+ const child = spawnLinuxCommand(command, args);
33
53
  const fullCommand = buildCommandString(command, args);
34
54
  let stdout = "";
35
55
  let stderr = "";
@@ -139,7 +159,7 @@ export async function wakeMonitor(options: LinuxUtilsOptions = {}): Promise<bool
139
159
  }
140
160
 
141
161
  const session = detectLinuxSession();
142
- const wakeCommands = [
162
+ const wakeCommands: Array<{ name: string; command: LinuxCommandName; args: string[] }> = [
143
163
  {
144
164
  name: "xset",
145
165
  command: "xset",