pi-notify 0.1.0 → 1.0.0

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Warren Winter
3
+ Copyright (c) 2026
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,64 +1,54 @@
1
- # Notifications for Pi (`pi-notify`)
1
+ # pi-notify
2
2
 
3
- Sends notifications when an agent turn finishes and took longer than a configurable threshold.
3
+ A [Pi](https://github.com/badlogic/pi-mono) extension that sends a native desktop notification when the agent finishes and is waiting for input.
4
4
 
5
- Supports:
6
- - desktop popups (macOS)
7
- - sounds (macOS `afplay`), with plenty of customization options
8
- - optional Pushover notifications (useful for Apple Watch / iOS)
5
+ ## Compatibility
9
6
 
10
- ## Install
7
+ | Terminal | Support | Protocol |
8
+ |----------|---------|----------|
9
+ | Ghostty | ✓ | OSC 777 |
10
+ | iTerm2 | ✓ | OSC 777 |
11
+ | WezTerm | ✓ | OSC 777 |
12
+ | rxvt-unicode | ✓ | OSC 777 |
13
+ | Kitty | ✓ | OSC 99 |
14
+ | Windows Terminal | ✓ | PowerShell toast |
15
+ | Terminal.app | ✗ | — |
16
+ | Alacritty | ✗ | — |
11
17
 
12
- From npm:
18
+ ## Install
13
19
 
14
20
  ```bash
15
21
  pi install npm:pi-notify
16
22
  ```
17
23
 
18
- From the dot314 git bundle (filtered install):
19
-
20
- Add to `~/.pi/agent/settings.json` (or replace an existing unfiltered `git:github.com/w-winter/dot314` entry):
21
-
22
- ```json
23
- {
24
- "packages": [
25
- {
26
- "source": "git:github.com/w-winter/dot314",
27
- "extensions": ["extensions/notify/index.ts"],
28
- "skills": [],
29
- "themes": [],
30
- "prompts": []
31
- }
32
- ]
33
- }
24
+ Or via git:
25
+
26
+ ```bash
27
+ pi install git:github.com/ferologics/pi-notify
34
28
  ```
35
29
 
36
- ## Setup
30
+ Restart Pi.
31
+
32
+ ## How it works
37
33
 
38
- Create your config file:
34
+ When Pi's agent finishes (`agent_end` event), the extension sends a notification via the appropriate protocol:
39
35
 
40
- - copy `notify.json.example` `notify.json`
36
+ - **OSC 777** (Ghostty, iTerm2, WezTerm, rxvt-unicode): Native escape sequence
37
+ - **OSC 99** (Kitty): Kitty's notification protocol, detected via `KITTY_WINDOW_ID`
38
+ - **Windows toast** (Windows Terminal): PowerShell notification, detected via `WT_SESSION`
41
39
 
42
- Location:
40
+ Clicking the notification focuses the terminal window/tab.
43
41
 
44
- - `~/.pi/agent/extensions/notify/notify.json`
42
+ ## What's OSC 777/99?
45
43
 
46
- (If the config file is missing, the extension will error with instructions.)
44
+ OSC = Operating System Command, part of ANSI escape sequences. Terminals use these for things beyond text formatting (change title, colors, notifications, etc.).
47
45
 
48
- ## Usage
46
+ `777` is the number rxvt-unicode picked for notifications. Ghostty, iTerm2, WezTerm adopted it. Kitty uses `99` with a more extensible protocol.
49
47
 
50
- - Command: `/notify`
51
- - Shortcut: `Alt+N` (toggle on/off)
48
+ ## Known Limitations
52
49
 
53
- Quick forms:
54
- - `/notify on|off`
55
- - `/notify popup` (toggle popup)
56
- - `/notify pushover` (toggle Pushover)
57
- - `/notify volume` (toggle constant ↔ timeScaled)
58
- - `/notify <seconds>` (set minimum duration threshold)
59
- - `/notify <sound-alias>` (set sound)
50
+ Terminal multiplexers (zellij, tmux, screen) create their own PTY and typically don't pass through OSC notification sequences. Run pi directly in your terminal for notifications to work.
60
51
 
61
- ## Notes
52
+ ## License
62
53
 
63
- - macOS-only out of the box (uses `osascript` + `afplay`)
64
- - Pushover requires `curl` and valid `userKey` + `apiToken` in config
54
+ MIT
package/index.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Pi Notify Extension
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)
9
+ */
10
+
11
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
12
+
13
+ function windowsToastScript(title: string, body: string): string {
14
+ const type = "Windows.UI.Notifications";
15
+ const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
16
+ const template = `[${type}.ToastTemplateType]::ToastText01`;
17
+ const toast = `[${type}.ToastNotification]::new($xml)`;
18
+ return [
19
+ `${mgr} > $null`,
20
+ `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
21
+ `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
22
+ `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
23
+ ].join("; ");
24
+ }
25
+
26
+ function notifyOSC777(title: string, body: string): void {
27
+ process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
28
+ }
29
+
30
+ 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
+ process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
33
+ process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
34
+ }
35
+
36
+ function notifyWindows(title: string, body: string): void {
37
+ const { execFile } = require("child_process");
38
+ execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
39
+ }
40
+
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) {
45
+ notifyOSC99(title, body);
46
+ } else {
47
+ notifyOSC777(title, body);
48
+ }
49
+ }
50
+
51
+ export default function (pi: ExtensionAPI) {
52
+ pi.on("agent_end", async () => {
53
+ notify("Pi", "Ready for input");
54
+ });
55
+ }
package/package.json CHANGED
@@ -1,37 +1,19 @@
1
1
  {
2
- "name": "pi-notify",
3
- "version": "0.1.0",
4
- "description": "Highly configurable desktop/sound/Pushover notifications when Pi agent turn finishes and took longer than a definable threshold",
5
- "keywords": ["pi-package", "pi", "pi-coding-agent"],
6
- "license": "MIT",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/w-winter/dot314.git",
10
- "directory": "packages/pi-notify"
11
- },
12
- "bugs": {
13
- "url": "https://github.com/w-winter/dot314/issues"
14
- },
15
- "homepage": "https://github.com/w-winter/dot314#readme",
16
- "pi": {
17
- "extensions": ["extensions/notify/index.ts"]
18
- },
19
- "peerDependencies": {
20
- "@mariozechner/pi-coding-agent": "*",
21
- "@mariozechner/pi-tui": "*"
22
- },
23
- "scripts": {
24
- "prepack": "node ../../scripts/pi-package-prepack.mjs"
25
- },
26
- "files": ["extensions/**", "README.md", "LICENSE", "package.json"],
27
- "dot314Prepack": {
28
- "copy": [
29
- { "from": "../../extensions/notify/index.ts", "to": "extensions/notify/index.ts" },
30
- {
31
- "from": "../../extensions/notify/notify.json.example",
32
- "to": "extensions/notify/notify.json.example"
33
- },
34
- { "from": "../../LICENSE", "to": "LICENSE" }
35
- ]
36
- }
2
+ "name": "pi-notify",
3
+ "version": "1.0.0",
4
+ "description": "Desktop notifications for Pi agent via OSC 777/99 and Windows toast",
5
+ "keywords": ["pi-package", "pi", "pi-coding-agent", "notifications", "osc"],
6
+ "author": "ferologics",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ferologics/pi-notify.git"
11
+ },
12
+ "homepage": "https://github.com/ferologics/pi-notify#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ferologics/pi-notify/issues"
15
+ },
16
+ "pi": {
17
+ "extensions": ["index.ts"]
18
+ }
37
19
  }
@@ -1,583 +0,0 @@
1
- /**
2
- * Desktop Notification Extension
3
- *
4
- * Sends a native desktop notification (with optional sound) when the agent finishes,
5
- * but only if the response took longer than a configurable threshold.
6
- *
7
- * Features:
8
- * - /notify command to configure (or quick: /notify on|off|popup|pushover|<seconds>|<sound>|volume)
9
- * - Configurable hotkey (default Alt+N) to toggle on/off
10
- * - Only notifies if agent turn took >= minDurationSeconds
11
- * - Configurable sounds: system sounds, custom paths, silent, or random
12
- * - "silent" reserved alias: no sound plays (popup only if enabled)
13
- * - "random" reserved alias: randomly picks from all sounds with paths
14
- * - Popup and sound can be toggled independently
15
- * - Volume modes: "constant" (always max) or "timeScaled" (louder for longer responses)
16
- * - Pushover integration for Apple Watch / iOS notifications
17
- * - Status indicator in footer (♫ sound, ↥ popup, ⚡︎ pushover)
18
- *
19
- * Configuration file: ~/.pi/agent/extensions/notify/notify.json
20
- *
21
- * Volume modes:
22
- * - "constant": Always plays at volume.max
23
- * - "timeScaled": Linear interpolation from volume.min (at threshold) to volume.max (at 4× threshold)
24
- *
25
- * Usage:
26
- * - Alt+N (or configured hotkey) to toggle notifications on/off
27
- * - /notify - open configuration menu
28
- * - /notify on|off - toggle directly
29
- * - /notify popup - toggle popup on/off
30
- * - /notify pushover - toggle Pushover on/off
31
- * - /notify volume - toggle between constant/timeScaled
32
- * - /notify 10 - set minimum duration to 10 seconds
33
- * - /notify glass - set sound to Glass (case-insensitive alias match)
34
- * - /notify silent - disable sound (popup only)
35
- * - /notify random - randomly select sound each notification
36
- */
37
-
38
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
39
- import { execSync, spawn } from "node:child_process";
40
- import { homedir } from "node:os";
41
- import { join, dirname } from "node:path";
42
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
43
- import { Key, type KeyId } from "@mariozechner/pi-tui";
44
-
45
- // =============================================================================
46
- // Configuration
47
- // =============================================================================
48
-
49
- // Configurable hotkey - change this to your preference
50
- // Examples: Key.ctrl("n"), Key.alt("n"), Key.ctrlShift("n")
51
- const TOGGLE_HOTKEY: KeyId = Key.alt("n");
52
-
53
- // =============================================================================
54
- // Types
55
- // =============================================================================
56
-
57
- interface SoundEntry {
58
- alias: string;
59
- path?: string; // undefined for reserved aliases like "silent" and "random"
60
- }
61
-
62
- interface VolumeConfig {
63
- mode: "constant" | "timeScaled";
64
- max: number; // 0.0 to 1.0+
65
- min: number; // 0.0 to 1.0+ (only used in timeScaled mode)
66
- }
67
-
68
- interface PushoverConfig {
69
- enabled: boolean;
70
- userKey: string;
71
- apiToken: string;
72
- }
73
-
74
- interface NotifyConfig {
75
- enabled: boolean;
76
- minDurationSeconds: number;
77
- sound: string; // alias reference
78
- showPopup: boolean;
79
- sounds: SoundEntry[];
80
- volume: VolumeConfig;
81
- pushover: PushoverConfig;
82
- }
83
-
84
- // =============================================================================
85
- // Config File Management
86
- // =============================================================================
87
-
88
- function getConfigPath(): string {
89
- return join(homedir(), ".pi", "agent", "extensions", "notify", "notify.json");
90
- }
91
-
92
- function loadConfig(): NotifyConfig {
93
- const configPath = getConfigPath();
94
-
95
- if (existsSync(configPath)) {
96
- try {
97
- const content = readFileSync(configPath, "utf-8");
98
- const parsed = JSON.parse(content);
99
-
100
- // Apply defaults for optional sections
101
- return {
102
- ...parsed,
103
- volume: {
104
- mode: "constant",
105
- max: 1.0,
106
- min: 0.25,
107
- ...parsed.volume,
108
- },
109
- pushover: {
110
- enabled: false,
111
- userKey: "",
112
- apiToken: "",
113
- ...parsed.pushover,
114
- },
115
- } as NotifyConfig;
116
- } catch {
117
- // Fall through to error
118
- }
119
- }
120
-
121
- throw new Error(
122
- `Notify extension: config file not found at ${configPath}. ` +
123
- `Please create it with the required structure (see extension docstring).`
124
- );
125
- }
126
-
127
- function saveConfig(config: NotifyConfig): void {
128
- const configPath = getConfigPath();
129
- const dir = dirname(configPath);
130
-
131
- if (!existsSync(dir)) {
132
- mkdirSync(dir, { recursive: true });
133
- }
134
-
135
- writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
136
- }
137
-
138
- // =============================================================================
139
- // Volume Calculation
140
- // =============================================================================
141
-
142
- function calculateVolume(config: NotifyConfig, elapsedSeconds: number): number {
143
- if (config.volume.mode === "constant") {
144
- return config.volume.max;
145
- }
146
-
147
- // timeScaled mode: linear interpolation from min to max
148
- // At 1× threshold: min volume
149
- // At 4× threshold: max volume
150
- const threshold = config.minDurationSeconds;
151
- const minTime = threshold;
152
- const maxTime = threshold * 4;
153
-
154
- if (elapsedSeconds <= minTime) {
155
- return config.volume.min;
156
- }
157
- if (elapsedSeconds >= maxTime) {
158
- return config.volume.max;
159
- }
160
-
161
- // Linear interpolation
162
- const t = (elapsedSeconds - minTime) / (maxTime - minTime);
163
- return config.volume.min + t * (config.volume.max - config.volume.min);
164
- }
165
-
166
- // =============================================================================
167
- // Sound Playback
168
- // =============================================================================
169
-
170
- function findSound(config: NotifyConfig, alias: string): SoundEntry | undefined {
171
- return config.sounds.find((s) => s.alias.toLowerCase() === alias.toLowerCase());
172
- }
173
-
174
- function getPlayableSound(config: NotifyConfig, alias: string): SoundEntry | undefined {
175
- const lowerAlias = alias.toLowerCase();
176
-
177
- // Handle "random" - pick a random sound (excluding silent and random)
178
- if (lowerAlias === "random") {
179
- const playableSounds = config.sounds.filter(
180
- (s) => s.path && s.alias.toLowerCase() !== "silent" && s.alias.toLowerCase() !== "random"
181
- );
182
- if (playableSounds.length === 0) return undefined;
183
- return playableSounds[Math.floor(Math.random() * playableSounds.length)];
184
- }
185
-
186
- return findSound(config, alias);
187
- }
188
-
189
- function playSound(soundEntry: SoundEntry | undefined, volume: number): void {
190
- if (!soundEntry || !soundEntry.path) {
191
- // silent or not found
192
- return;
193
- }
194
-
195
- // Spawn detached so sound plays without blocking input
196
- const child = spawn("afplay", ["-v", String(volume), soundEntry.path], {
197
- detached: true,
198
- stdio: "ignore",
199
- });
200
- child.unref();
201
- }
202
-
203
- // =============================================================================
204
- // Pushover Integration
205
- // =============================================================================
206
-
207
- function sendPushover(config: NotifyConfig, title: string, message: string): void {
208
- if (!config.pushover.enabled || !config.pushover.userKey || !config.pushover.apiToken) {
209
- return;
210
- }
211
-
212
- // Spawn curl detached so it doesn't block
213
- const child = spawn("curl", [
214
- "-s",
215
- "-X", "POST",
216
- "https://api.pushover.net/1/messages.json",
217
- "--data-urlencode", `token=${config.pushover.apiToken}`,
218
- "--data-urlencode", `user=${config.pushover.userKey}`,
219
- "--data-urlencode", `title=${title}`,
220
- "--data-urlencode", `message=${message}`,
221
- ], {
222
- detached: true,
223
- stdio: "ignore",
224
- });
225
- child.unref();
226
- }
227
-
228
- // =============================================================================
229
- // Notification
230
- // =============================================================================
231
-
232
- function notify(title: string, body: string, config: NotifyConfig, elapsedSeconds: number): void {
233
- // Show popup notification if enabled
234
- if (config.showPopup) {
235
- try {
236
- execSync(`osascript -e 'display notification "${body}" with title "${title}"'`);
237
- } catch {
238
- // Silently fail
239
- }
240
- }
241
-
242
- // Play sound with calculated volume
243
- const soundEntry = getPlayableSound(config, config.sound);
244
- const volume = calculateVolume(config, elapsedSeconds);
245
- playSound(soundEntry, volume);
246
-
247
- // Send Pushover notification
248
- sendPushover(config, title, body);
249
- }
250
-
251
- // =============================================================================
252
- // Extension
253
- // =============================================================================
254
-
255
- export default function notifyExtension(pi: ExtensionAPI) {
256
- let config: NotifyConfig;
257
- let agentStartTime: number | null = null;
258
-
259
- // =========================================================================
260
- // Status Display
261
- // =========================================================================
262
-
263
- function updateStatus(ctx: ExtensionContext): void {
264
- if (!ctx.hasUI) return;
265
-
266
- if (config.enabled) {
267
- const lowerSound = config.sound.toLowerCase();
268
- const soundIndicator = lowerSound === "silent" ? "" : "♫";
269
- const popupIndicator = config.showPopup ? "↑" : "";
270
- const pushoverIndicator = config.pushover.enabled ? "⚡︎" : "";
271
- ctx.ui.setStatus(
272
- "notify",
273
- ctx.ui.theme.fg("success", `${soundIndicator}${popupIndicator}${pushoverIndicator} ${config.minDurationSeconds}s`)
274
- );
275
- } else {
276
- ctx.ui.setStatus("notify", ctx.ui.theme.fg("muted", ""));
277
- }
278
- }
279
-
280
- // =========================================================================
281
- // Toggle Functions
282
- // =========================================================================
283
-
284
- function toggleEnabled(ctx: ExtensionContext): void {
285
- config.enabled = !config.enabled;
286
- saveConfig(config);
287
-
288
- if (ctx.hasUI) {
289
- ctx.ui.notify(
290
- config.enabled
291
- ? `Notifications enabled (≥${config.minDurationSeconds}s)`
292
- : "Notifications disabled",
293
- "info"
294
- );
295
- }
296
-
297
- updateStatus(ctx);
298
- }
299
-
300
- function togglePopup(ctx: ExtensionContext): void {
301
- config.showPopup = !config.showPopup;
302
- saveConfig(config);
303
-
304
- if (ctx.hasUI) {
305
- ctx.ui.notify(
306
- config.showPopup ? "Popup notifications enabled" : "Popup notifications disabled",
307
- "info"
308
- );
309
- }
310
-
311
- updateStatus(ctx);
312
- }
313
-
314
- function togglePushover(ctx: ExtensionContext): void {
315
- config.pushover.enabled = !config.pushover.enabled;
316
- saveConfig(config);
317
-
318
- if (ctx.hasUI) {
319
- ctx.ui.notify(
320
- config.pushover.enabled ? "Pushover notifications enabled" : "Pushover notifications disabled",
321
- "info"
322
- );
323
- }
324
-
325
- updateStatus(ctx);
326
- }
327
-
328
- function toggleVolumeMode(ctx: ExtensionContext): void {
329
- config.volume.mode = config.volume.mode === "constant" ? "timeScaled" : "constant";
330
- saveConfig(config);
331
-
332
- if (ctx.hasUI) {
333
- ctx.ui.notify(
334
- config.volume.mode === "constant"
335
- ? `Volume mode: constant (${config.volume.max})`
336
- : `Volume mode: timeScaled (${config.volume.min} → ${config.volume.max})`,
337
- "info"
338
- );
339
- }
340
- }
341
-
342
- // =========================================================================
343
- // Hotkey Registration
344
- // =========================================================================
345
-
346
- pi.registerShortcut(TOGGLE_HOTKEY, {
347
- description: "Toggle notifications",
348
- handler: async (ctx) => {
349
- toggleEnabled(ctx);
350
- },
351
- });
352
-
353
- // =========================================================================
354
- // Command Registration
355
- // =========================================================================
356
-
357
- pi.registerCommand("notify", {
358
- description: "Configure desktop notifications",
359
- handler: async (args, ctx) => {
360
- // Quick subcommands
361
- if (args) {
362
- const arg = args.trim().toLowerCase();
363
-
364
- // /notify on
365
- if (arg === "on") {
366
- config.enabled = true;
367
- saveConfig(config);
368
- ctx.ui.notify("Notifications enabled", "info");
369
- updateStatus(ctx);
370
- return;
371
- }
372
-
373
- // /notify off
374
- if (arg === "off") {
375
- config.enabled = false;
376
- saveConfig(config);
377
- ctx.ui.notify("Notifications disabled", "info");
378
- updateStatus(ctx);
379
- return;
380
- }
381
-
382
- // /notify popup
383
- if (arg === "popup") {
384
- togglePopup(ctx);
385
- return;
386
- }
387
-
388
- // /notify pushover
389
- if (arg === "pushover") {
390
- togglePushover(ctx);
391
- return;
392
- }
393
-
394
- // /notify volume
395
- if (arg === "volume") {
396
- toggleVolumeMode(ctx);
397
- return;
398
- }
399
-
400
- // /notify <number> - set duration
401
- const num = parseInt(arg, 10);
402
- if (!isNaN(num) && num >= 0) {
403
- config.minDurationSeconds = num;
404
- saveConfig(config);
405
- ctx.ui.notify(`Notification threshold set to ${num} seconds`, "info");
406
- updateStatus(ctx);
407
- return;
408
- }
409
-
410
- // /notify <sound alias> - set sound (case-insensitive match)
411
- const matchedSound = findSound(config, arg);
412
- if (matchedSound) {
413
- config.sound = matchedSound.alias;
414
- saveConfig(config);
415
- if (matchedSound.path) {
416
- playSound(matchedSound, config.volume.max); // Preview at max volume
417
- }
418
- ctx.ui.notify(`Notification sound set to ${matchedSound.alias}`, "info");
419
- updateStatus(ctx);
420
- return;
421
- }
422
-
423
- // Unknown arg - show help
424
- ctx.ui.notify(
425
- `Unknown argument: ${args}\nUse: on, off, popup, pushover, volume, <seconds>, or <sound alias>`,
426
- "warning"
427
- );
428
- return;
429
- }
430
-
431
- // No args - show interactive menu
432
- const menuItems = [
433
- `${config.enabled ? "Disable" : "Enable"} notifications`,
434
- `${config.showPopup ? "Disable" : "Enable"} popup`,
435
- `${config.pushover.enabled ? "Disable" : "Enable"} Pushover (watch)`,
436
- `Volume mode: ${config.volume.mode} (tap to toggle)`,
437
- `Set max volume (current: ${config.volume.max})`,
438
- ...(config.volume.mode === "timeScaled" ? [`Set min volume (current: ${config.volume.min})`] : []),
439
- `Set duration threshold (current: ${config.minDurationSeconds}s)`,
440
- `Change sound (current: ${config.sound})`,
441
- "Test notification",
442
- ];
443
-
444
- const choice = await ctx.ui.select("Notification Settings", menuItems);
445
-
446
- if (choice === null) return;
447
-
448
- // Toggle notifications
449
- if (choice === menuItems[0]) {
450
- toggleEnabled(ctx);
451
- return;
452
- }
453
-
454
- // Toggle popup
455
- if (choice === menuItems[1]) {
456
- togglePopup(ctx);
457
- return;
458
- }
459
-
460
- // Toggle Pushover
461
- if (choice === menuItems[2]) {
462
- togglePushover(ctx);
463
- return;
464
- }
465
-
466
- // Toggle volume mode
467
- if (choice === menuItems[3]) {
468
- toggleVolumeMode(ctx);
469
- return;
470
- }
471
-
472
- // Set max volume
473
- if (choice === menuItems[4]) {
474
- const input = await ctx.ui.input("Max volume (0.0 - 1.0+)", String(config.volume.max));
475
- if (input !== null) {
476
- const vol = parseFloat(input);
477
- if (!isNaN(vol) && vol >= 0) {
478
- config.volume.max = vol;
479
- saveConfig(config);
480
- ctx.ui.notify(`Max volume set to ${vol}`, "info");
481
- } else {
482
- ctx.ui.notify("Invalid volume", "error");
483
- }
484
- }
485
- return;
486
- }
487
-
488
- // Set min volume (only in timeScaled mode)
489
- if (config.volume.mode === "timeScaled" && choice === menuItems[5]) {
490
- const input = await ctx.ui.input("Min volume (0.0 - 1.0+)", String(config.volume.min));
491
- if (input !== null) {
492
- const vol = parseFloat(input);
493
- if (!isNaN(vol) && vol >= 0) {
494
- config.volume.min = vol;
495
- saveConfig(config);
496
- ctx.ui.notify(`Min volume set to ${vol}`, "info");
497
- } else {
498
- ctx.ui.notify("Invalid volume", "error");
499
- }
500
- }
501
- return;
502
- }
503
-
504
- // Set duration - index shifts based on whether min volume is shown
505
- const durationIndex = config.volume.mode === "timeScaled" ? 6 : 5;
506
- if (choice === menuItems[durationIndex]) {
507
- const input = await ctx.ui.input(
508
- "Minimum duration (seconds)",
509
- String(config.minDurationSeconds)
510
- );
511
- if (input !== null) {
512
- const num = parseInt(input, 10);
513
- if (!isNaN(num) && num >= 0) {
514
- config.minDurationSeconds = num;
515
- saveConfig(config);
516
- ctx.ui.notify(`Threshold set to ${num} seconds`, "info");
517
- updateStatus(ctx);
518
- } else {
519
- ctx.ui.notify("Invalid number", "error");
520
- }
521
- }
522
- return;
523
- }
524
-
525
- // Change sound
526
- const soundIndex = config.volume.mode === "timeScaled" ? 7 : 6;
527
- if (choice === menuItems[soundIndex]) {
528
- const soundAliases = config.sounds.map((s) => s.alias);
529
- const soundChoice = await ctx.ui.select("Select sound", soundAliases);
530
- if (soundChoice !== null) {
531
- config.sound = soundChoice;
532
- saveConfig(config);
533
- const soundEntry = findSound(config, soundChoice);
534
- if (soundEntry?.path) {
535
- playSound(soundEntry, config.volume.max); // Preview at max volume
536
- }
537
- ctx.ui.notify(`Sound set to ${soundChoice}`, "info");
538
- updateStatus(ctx);
539
- }
540
- return;
541
- }
542
-
543
- // Test notification
544
- const testIndex = config.volume.mode === "timeScaled" ? 8 : 7;
545
- if (choice === menuItems[testIndex]) {
546
- // Test at 4x threshold to demonstrate max volume
547
- notify("Pi", "℟", config, config.minDurationSeconds * 4);
548
- return;
549
- }
550
- },
551
- });
552
-
553
- // =========================================================================
554
- // Agent Lifecycle Events
555
- // =========================================================================
556
-
557
- pi.on("agent_start", async () => {
558
- agentStartTime = Date.now();
559
- });
560
-
561
- pi.on("agent_end", async () => {
562
- if (!config.enabled || agentStartTime === null) {
563
- agentStartTime = null;
564
- return;
565
- }
566
-
567
- const elapsedSeconds = (Date.now() - agentStartTime) / 1000;
568
- agentStartTime = null;
569
-
570
- if (elapsedSeconds >= config.minDurationSeconds) {
571
- notify("Pi", "℟", config, elapsedSeconds);
572
- }
573
- });
574
-
575
- // =========================================================================
576
- // Session Initialization
577
- // =========================================================================
578
-
579
- pi.on("session_start", async (_event, ctx) => {
580
- config = loadConfig();
581
- updateStatus(ctx);
582
- });
583
- }
@@ -1,52 +0,0 @@
1
- {
2
- "enabled": true,
3
- "minDurationSeconds": 10,
4
- "sound": "silent",
5
- "showPopup": false,
6
- "sounds": [
7
- {
8
- "alias": "silent"
9
- },
10
- {
11
- "alias": "random"
12
- },
13
- {
14
- "alias": "Funk",
15
- "path": "/System/Library/Sounds/Funk.aiff"
16
- },
17
- {
18
- "alias": "Glass",
19
- "path": "/System/Library/Sounds/Glass.aiff"
20
- },
21
- {
22
- "alias": "Hero",
23
- "path": "/System/Library/Sounds/Hero.aiff"
24
- },
25
- {
26
- "alias": "Submarine",
27
- "path": "/System/Library/Sounds/Submarine.aiff"
28
- },
29
- {
30
- "alias": "dubdelay1",
31
- "path": "/Users/yourUser/Documents/notification_sounds/dubdelay1.mp3"
32
- },
33
- {
34
- "alias": "dubdelay2",
35
- "path": "/Users/yourUser/Documents/notification_sounds/dubdelay2.mp3"
36
- },
37
- {
38
- "alias": "liquid",
39
- "path": "/Users/yourUser/Documents/notification_sounds/liquid-notif.mp3"
40
- }
41
- ],
42
- "volume": {
43
- "mode": "timeScaled",
44
- "max": 1,
45
- "min": 0.1
46
- },
47
- "pushover": {
48
- "enabled": true,
49
- "userKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
50
- "apiToken": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
51
- }
52
- }