@diegopetrucci/pi-extensions 0.1.8 → 0.1.9

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/README.md CHANGED
@@ -10,7 +10,7 @@ A collection of [pi](https://github.com/badlogic/pi-mono) agent extensions I mad
10
10
  | [`oracle`](./extensions/oracle) | Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, covers pi’s built-in providers with hardcoded rankings, sets reasoning to xhigh by default, and shows live status while running. |
11
11
  | [`permission-gate`](./extensions/permission-gate) | Prompts for confirmation before dangerous bash commands like `rm -rf`, `sudo`, and `chmod 777`. |
12
12
  | [`confirm-destructive`](./extensions/confirm-destructive) | Confirms before destructive session actions like clear, switch, and fork. |
13
- | [`notify`](./extensions/notify) | Sends a terminal or desktop notification when pi finishes and is ready for input. |
13
+ | [`notify`](./extensions/notify) | Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input. |
14
14
 
15
15
  ## Install
16
16
 
@@ -25,7 +25,7 @@ pi install git:github.com/diegopetrucci/pi-extensions
25
25
  Or pin to a tagged version:
26
26
 
27
27
  ```bash
28
- pi install git:github.com/diegopetrucci/pi-extensions@v0.1.8
28
+ pi install git:github.com/diegopetrucci/pi-extensions@v0.1.9
29
29
  ```
30
30
 
31
31
  ### npm
@@ -1,14 +1,37 @@
1
1
  # notify
2
2
 
3
- A small pi extension that sends a terminal or desktop notification when the agent finishes and is waiting for input.
3
+ A pi extension that sends notifications when the agent finishes and is waiting for input.
4
4
 
5
- This is adapted from the original `notify.ts` example in [`badlogic/pi-mono`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/notify.ts) and kept basically the same.
5
+ This started from the original `notify.ts` example in [`badlogic/pi-mono`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/examples/extensions/notify.ts), but now supports multiple notification channels and JSON configuration.
6
6
 
7
- ## Supported notification backends
7
+ ## Supported notification channels
8
+
9
+ ### Terminal notifications
8
10
 
9
11
  - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
10
12
  - OSC 99: Kitty
11
- - Windows toast: Windows Terminal / WSL
13
+
14
+ ### Desktop notifications
15
+
16
+ - macOS Notification Center via `osascript`
17
+ - Linux desktop notifications via `notify-send`
18
+ - Windows toast notifications via `powershell.exe` / Windows Terminal / WSL
19
+
20
+ ### Bells and sounds
21
+
22
+ - terminal bell (`\a`)
23
+ - macOS sound playback via `afplay`
24
+ - Linux sound playback via `canberra-gtk-play` or `paplay`
25
+ - Windows beep via `powershell.exe`
26
+
27
+ By default, all channels are enabled:
28
+
29
+ - terminal notification
30
+ - desktop notification
31
+ - bell
32
+ - sound
33
+
34
+ The extension automatically picks the appropriate backend for the current environment.
12
35
 
13
36
  ## Install
14
37
 
@@ -36,8 +59,67 @@ Then reload pi:
36
59
  /reload
37
60
  ```
38
61
 
62
+ ## Configuration
63
+
64
+ Config files are merged, with project config overriding global config:
65
+
66
+ - `~/.pi/agent/extensions/notify.json`
67
+ - `<project>/.pi/notify.json`
68
+
69
+ A ready-to-copy sample file is included at [`notify.example.json`](./notify.example.json).
70
+
71
+ Example:
72
+
73
+ ```json
74
+ {
75
+ "enabled": true,
76
+ "onlyWhenInteractive": true,
77
+ "title": "Pi",
78
+ "body": "Ready for input",
79
+ "channels": {
80
+ "terminal": true,
81
+ "desktop": true,
82
+ "bell": true,
83
+ "sound": true
84
+ },
85
+ "terminal": {
86
+ "backend": "auto"
87
+ },
88
+ "desktop": {
89
+ "backend": "auto"
90
+ },
91
+ "sound": {
92
+ "backend": "auto",
93
+ "name": "Glass",
94
+ "linuxSoundId": "complete",
95
+ "frequencyHz": 1000,
96
+ "durationMs": 250,
97
+ "command": ""
98
+ }
99
+ }
100
+ ```
101
+
102
+ ### Config fields
103
+
104
+ - `enabled`: master on/off switch
105
+ - `onlyWhenInteractive`: skip notifications in print / non-UI mode
106
+ - `title`: notification title
107
+ - `body`: notification body
108
+ - `channels.terminal`: enable terminal notification output
109
+ - `channels.desktop`: enable OS desktop notifications
110
+ - `channels.bell`: enable terminal bell
111
+ - `channels.sound`: enable sound playback
112
+ - `terminal.backend`: `auto`, `osc777`, `osc99`, `none`
113
+ - `desktop.backend`: `auto`, `macos`, `linux`, `windows-toast`, `none`
114
+ - `sound.backend`: `auto`, `macos`, `linux`, `windows-beep`, `command`, `none`
115
+ - `sound.name`: macOS system sound name, like `Glass` or `Hero`
116
+ - `sound.linuxSoundId`: freedesktop sound id, like `complete`
117
+ - `sound.frequencyHz`: Windows beep frequency
118
+ - `sound.durationMs`: Windows beep duration
119
+ - `sound.command`: custom shell command when `sound.backend` is `command`
120
+
39
121
  ## Notes
40
122
 
41
123
  - Hooks the `agent_end` event.
42
- - Sends `Pi` / `Ready for input` when the agent finishes.
43
- - Chooses the notification backend from the current terminal environment.
124
+ - Default message is `Pi` / `Ready for input`.
125
+ - Terminal, desktop, bell, and sound channels can be enabled independently.
@@ -1,14 +1,125 @@
1
1
  /**
2
2
  * Pi Notify Extension
3
3
  *
4
- * Sends a native terminal notification when Pi agent is done and waiting for input.
5
- * Supports multiple terminal protocols:
6
- * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
7
- * - OSC 99: Kitty
8
- * - Windows toast: Windows Terminal (WSL)
4
+ * Sends notifications when Pi agent is done and waiting for input.
5
+ * Supports multiple channels:
6
+ * - terminal notifications: OSC 777 and OSC 99
7
+ * - desktop notifications: macOS Notification Center, Linux notify-send, Windows toast
8
+ * - terminal bell
9
+ * - sound playback
10
+ *
11
+ * Config files (project overrides global):
12
+ * - ~/.pi/agent/extensions/notify.json
13
+ * - <cwd>/.pi/notify.json
9
14
  */
10
15
 
16
+ import { execFile } from "node:child_process";
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { join } from "node:path";
11
19
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
20
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
21
+
22
+ type TerminalBackend = "auto" | "osc777" | "osc99" | "none";
23
+ type DesktopBackend = "auto" | "macos" | "linux" | "windows-toast" | "none";
24
+ type SoundBackend = "auto" | "macos" | "linux" | "windows-beep" | "command" | "none";
25
+
26
+ interface NotifyConfig {
27
+ enabled: boolean;
28
+ onlyWhenInteractive: boolean;
29
+ title: string;
30
+ body: string;
31
+ channels: {
32
+ terminal: boolean;
33
+ desktop: boolean;
34
+ bell: boolean;
35
+ sound: boolean;
36
+ };
37
+ terminal: {
38
+ backend: TerminalBackend;
39
+ };
40
+ desktop: {
41
+ backend: DesktopBackend;
42
+ };
43
+ sound: {
44
+ backend: SoundBackend;
45
+ name: string;
46
+ linuxSoundId: string;
47
+ frequencyHz: number;
48
+ durationMs: number;
49
+ command: string;
50
+ };
51
+ }
52
+
53
+ const DEFAULT_CONFIG: NotifyConfig = {
54
+ enabled: true,
55
+ onlyWhenInteractive: true,
56
+ title: "Pi",
57
+ body: "Ready for input",
58
+ channels: {
59
+ terminal: true,
60
+ desktop: true,
61
+ bell: true,
62
+ sound: true,
63
+ },
64
+ terminal: {
65
+ backend: "auto",
66
+ },
67
+ desktop: {
68
+ backend: "auto",
69
+ },
70
+ sound: {
71
+ backend: "auto",
72
+ name: "Glass",
73
+ linuxSoundId: "complete",
74
+ frequencyHz: 1000,
75
+ durationMs: 250,
76
+ command: "",
77
+ },
78
+ };
79
+
80
+ function readConfigFile(path: string): Partial<NotifyConfig> {
81
+ if (!existsSync(path)) return {};
82
+
83
+ try {
84
+ return JSON.parse(readFileSync(path, "utf-8")) as Partial<NotifyConfig>;
85
+ } catch (error) {
86
+ console.error(`Warning: Could not parse ${path}: ${error}`);
87
+ return {};
88
+ }
89
+ }
90
+
91
+ function mergeConfig(base: NotifyConfig, overrides: Partial<NotifyConfig>): NotifyConfig {
92
+ return {
93
+ ...base,
94
+ ...overrides,
95
+ channels: {
96
+ ...base.channels,
97
+ ...overrides.channels,
98
+ },
99
+ terminal: {
100
+ ...base.terminal,
101
+ ...overrides.terminal,
102
+ },
103
+ desktop: {
104
+ ...base.desktop,
105
+ ...overrides.desktop,
106
+ },
107
+ sound: {
108
+ ...base.sound,
109
+ ...overrides.sound,
110
+ },
111
+ };
112
+ }
113
+
114
+ function loadConfig(cwd: string): NotifyConfig {
115
+ const globalConfig = readConfigFile(join(getAgentDir(), "extensions", "notify.json"));
116
+ const projectConfig = readConfigFile(join(cwd, ".pi", "notify.json"));
117
+ return mergeConfig(mergeConfig(DEFAULT_CONFIG, globalConfig), projectConfig);
118
+ }
119
+
120
+ function powershellString(value: string): string {
121
+ return `'${value.replace(/'/g, "''")}'`;
122
+ }
12
123
 
13
124
  function windowsToastScript(title: string, body: string): string {
14
125
  const type = "Windows.UI.Notifications";
@@ -18,8 +129,8 @@ function windowsToastScript(title: string, body: string): string {
18
129
  return [
19
130
  `${mgr} > $null`,
20
131
  `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
21
- `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
22
- `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
132
+ `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode(${powershellString(body)})) > $null`,
133
+ `[${type}.ToastNotificationManager]::CreateToastNotifier(${powershellString(title)}).Show(${toast})`,
23
134
  ].join("; ");
24
135
  }
25
136
 
@@ -28,28 +139,136 @@ function notifyOSC777(title: string, body: string): void {
28
139
  }
29
140
 
30
141
  function notifyOSC99(title: string, body: string): void {
31
- // Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
32
142
  process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
33
143
  process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
34
144
  }
35
145
 
36
- function notifyWindows(title: string, body: string): void {
37
- const { execFile } = require("child_process");
38
- execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
146
+ function ringBell(): void {
147
+ process.stdout.write("\x07");
148
+ }
149
+
150
+ function runCommand(command: string, args: string[]): Promise<boolean> {
151
+ return new Promise((resolve) => {
152
+ execFile(command, args, (error) => resolve(!error));
153
+ });
154
+ }
155
+
156
+ function runShellCommand(command: string): Promise<boolean> {
157
+ if (process.platform === "win32") {
158
+ return runCommand("cmd.exe", ["/d", "/s", "/c", command]);
159
+ }
160
+
161
+ return runCommand(process.env.SHELL || "/bin/sh", ["-lc", command]);
162
+ }
163
+
164
+ function detectTerminalBackend(config: NotifyConfig): Exclude<TerminalBackend, "auto"> {
165
+ if (config.terminal.backend !== "auto") return config.terminal.backend;
166
+ if (process.env.KITTY_WINDOW_ID) return "osc99";
167
+ return "osc777";
168
+ }
169
+
170
+ function detectDesktopBackend(config: NotifyConfig): Exclude<DesktopBackend, "auto"> {
171
+ if (config.desktop.backend !== "auto") return config.desktop.backend;
172
+ if (process.env.WT_SESSION || process.env.WSL_DISTRO_NAME) return "windows-toast";
173
+ if (process.platform === "darwin") return "macos";
174
+ if (process.platform === "linux") return "linux";
175
+ if (process.platform === "win32") return "windows-toast";
176
+ return "none";
39
177
  }
40
178
 
41
- function notify(title: string, body: string): void {
42
- if (process.env.WT_SESSION) {
43
- notifyWindows(title, body);
44
- } else if (process.env.KITTY_WINDOW_ID) {
179
+ function detectSoundBackend(config: NotifyConfig): Exclude<SoundBackend, "auto"> {
180
+ if (config.sound.backend !== "auto") return config.sound.backend;
181
+ if (process.env.WT_SESSION || process.platform === "win32" || process.env.WSL_DISTRO_NAME) return "windows-beep";
182
+ if (process.platform === "darwin") return "macos";
183
+ if (process.platform === "linux") return "linux";
184
+ return "none";
185
+ }
186
+
187
+ function sendTerminalNotification(title: string, body: string, backend: Exclude<TerminalBackend, "auto">): void {
188
+ if (backend === "osc99") {
45
189
  notifyOSC99(title, body);
46
- } else {
190
+ return;
191
+ }
192
+ if (backend === "osc777") {
47
193
  notifyOSC777(title, body);
48
194
  }
49
195
  }
50
196
 
51
- export default function (pi: ExtensionAPI) {
52
- pi.on("agent_end", async () => {
53
- notify("Pi", "Ready for input");
197
+ function appleScriptString(value: string): string {
198
+ return JSON.stringify(value);
199
+ }
200
+
201
+ function sendDesktopNotification(
202
+ title: string,
203
+ body: string,
204
+ backend: Exclude<DesktopBackend, "auto">,
205
+ ): Promise<boolean> {
206
+ if (backend === "windows-toast") {
207
+ return runCommand("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
208
+ }
209
+ if (backend === "macos") {
210
+ return runCommand("osascript", ["-e", `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`]);
211
+ }
212
+ if (backend === "linux") {
213
+ return runCommand("notify-send", [title, body]);
214
+ }
215
+ return Promise.resolve(false);
216
+ }
217
+
218
+ async function playSound(config: NotifyConfig, backend: Exclude<SoundBackend, "auto">): Promise<boolean> {
219
+ if (backend === "command") {
220
+ if (!config.sound.command.trim()) return false;
221
+ return runShellCommand(config.sound.command);
222
+ }
223
+
224
+ if (backend === "windows-beep") {
225
+ return runCommand("powershell.exe", [
226
+ "-NoProfile",
227
+ "-Command",
228
+ `[console]::beep(${config.sound.frequencyHz}, ${config.sound.durationMs})`,
229
+ ]);
230
+ }
231
+
232
+ if (backend === "macos") {
233
+ return runCommand("afplay", [`/System/Library/Sounds/${config.sound.name}.aiff`]);
234
+ }
235
+
236
+ if (backend === "linux") {
237
+ const soundId = config.sound.linuxSoundId;
238
+ const viaCanberra = await runCommand("canberra-gtk-play", ["-i", soundId]);
239
+ if (viaCanberra) return true;
240
+ return runCommand("paplay", [`/usr/share/sounds/freedesktop/stereo/${soundId}.oga`]);
241
+ }
242
+
243
+ return false;
244
+ }
245
+
246
+ export default function notifyExtension(pi: ExtensionAPI) {
247
+ pi.on("agent_end", async (_event, ctx) => {
248
+ const config = loadConfig(ctx.cwd);
249
+ if (!config.enabled) return;
250
+ if (config.onlyWhenInteractive && !ctx.hasUI) return;
251
+
252
+ const tasks: Array<Promise<unknown>> = [];
253
+
254
+ if (config.channels.terminal) {
255
+ sendTerminalNotification(config.title, config.body, detectTerminalBackend(config));
256
+ }
257
+
258
+ if (config.channels.desktop) {
259
+ tasks.push(sendDesktopNotification(config.title, config.body, detectDesktopBackend(config)));
260
+ }
261
+
262
+ if (config.channels.bell) {
263
+ ringBell();
264
+ }
265
+
266
+ if (config.channels.sound) {
267
+ tasks.push(playSound(config, detectSoundBackend(config)));
268
+ }
269
+
270
+ if (tasks.length > 0) {
271
+ await Promise.allSettled(tasks);
272
+ }
54
273
  });
55
274
  }
@@ -0,0 +1,26 @@
1
+ {
2
+ "enabled": true,
3
+ "onlyWhenInteractive": true,
4
+ "title": "Pi",
5
+ "body": "Ready for input",
6
+ "channels": {
7
+ "terminal": true,
8
+ "desktop": true,
9
+ "bell": true,
10
+ "sound": true
11
+ },
12
+ "terminal": {
13
+ "backend": "auto"
14
+ },
15
+ "desktop": {
16
+ "backend": "auto"
17
+ },
18
+ "sound": {
19
+ "backend": "auto",
20
+ "name": "Glass",
21
+ "linuxSoundId": "complete",
22
+ "frequencyHz": 1000,
23
+ "durationMs": 250,
24
+ "command": ""
25
+ }
26
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-notify",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A pi extension that sends a notification when the agent is ready for input.",
5
5
  "keywords": ["pi-package", "pi", "notification", "terminal"],
6
6
  "license": "MIT",
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "index.ts",
14
- "README.md"
14
+ "README.md",
15
+ "notify.example.json"
15
16
  ],
16
17
  "publishConfig": {
17
18
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-extensions",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "A collection of pi extensions, including a minimal custom footer, an Amp-style oracle, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
5
5
  "keywords": ["pi-package", "pi", "terminal", "agent"],
6
6
  "license": "MIT",