pi-notify 0.1.1 → 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,62 +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
33
+
34
+ When Pi's agent finishes (`agent_end` event), the extension sends a notification via the appropriate protocol:
37
35
 
38
- Create your config file:
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`
39
39
 
40
- - copy `notify.json.example` `notify.json`
40
+ Clicking the notification focuses the terminal window/tab.
41
41
 
42
- Location:
42
+ ## What's OSC 777/99?
43
43
 
44
- - `~/.pi/agent/extensions/notify/notify.json`
44
+ OSC = Operating System Command, part of ANSI escape sequences. Terminals use these for things beyond text formatting (change title, colors, notifications, etc.).
45
45
 
46
- ## Usage
46
+ `777` is the number rxvt-unicode picked for notifications. Ghostty, iTerm2, WezTerm adopted it. Kitty uses `99` with a more extensible protocol.
47
47
 
48
- - Command: `/notify`
49
- - Shortcut: `Alt+N` (toggle on/off)
48
+ ## Known Limitations
50
49
 
51
- Quick forms:
52
- - `/notify on|off`
53
- - `/notify popup` (toggle popup)
54
- - `/notify pushover` (toggle Pushover)
55
- - `/notify volume` (toggle constant ↔ timeScaled)
56
- - `/notify <seconds>` (set minimum duration threshold)
57
- - `/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.
58
51
 
59
- ## Notes
52
+ ## License
60
53
 
61
- - macOS-only out of the box (uses `osascript` + `afplay`)
62
- - 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.1",
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,610 +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
- * - If missing, the extension will create it on first run with safe defaults
21
- *
22
- * Volume modes:
23
- * - "constant": Always plays at volume.max
24
- * - "timeScaled": Linear interpolation from volume.min (at threshold) to volume.max (at 4× threshold)
25
- *
26
- * Usage:
27
- * - Alt+N (or configured hotkey) to toggle notifications on/off
28
- * - /notify - open configuration menu
29
- * - /notify on|off - toggle directly
30
- * - /notify popup - toggle popup on/off
31
- * - /notify pushover - toggle Pushover on/off
32
- * - /notify volume - toggle between constant/timeScaled
33
- * - /notify 10 - set minimum duration to 10 seconds
34
- * - /notify glass - set sound to Glass (case-insensitive alias match)
35
- * - /notify silent - disable sound (popup only)
36
- * - /notify random - randomly select sound each notification
37
- */
38
-
39
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
40
- import { execSync, spawn } from "node:child_process";
41
- import { homedir } from "node:os";
42
- import { join, dirname } from "node:path";
43
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
44
- import { Key, type KeyId } from "@mariozechner/pi-tui";
45
-
46
- // =============================================================================
47
- // Configuration
48
- // =============================================================================
49
-
50
- // Configurable hotkey - change this to your preference
51
- // Examples: Key.ctrl("n"), Key.alt("n"), Key.ctrlShift("n")
52
- const TOGGLE_HOTKEY: KeyId = Key.alt("n");
53
-
54
- // =============================================================================
55
- // Types
56
- // =============================================================================
57
-
58
- interface SoundEntry {
59
- alias: string;
60
- path?: string; // undefined for reserved aliases like "silent" and "random"
61
- }
62
-
63
- interface VolumeConfig {
64
- mode: "constant" | "timeScaled";
65
- max: number; // 0.0 to 1.0+
66
- min: number; // 0.0 to 1.0+ (only used in timeScaled mode)
67
- }
68
-
69
- interface PushoverConfig {
70
- enabled: boolean;
71
- userKey: string;
72
- apiToken: string;
73
- }
74
-
75
- interface NotifyConfig {
76
- enabled: boolean;
77
- minDurationSeconds: number;
78
- sound: string; // alias reference
79
- showPopup: boolean;
80
- sounds: SoundEntry[];
81
- volume: VolumeConfig;
82
- pushover: PushoverConfig;
83
- }
84
-
85
- // =============================================================================
86
- // Config File Management
87
- // =============================================================================
88
-
89
- function getConfigPath(): string {
90
- return join(homedir(), ".pi", "agent", "extensions", "notify", "notify.json");
91
- }
92
-
93
- const DEFAULT_CONFIG: NotifyConfig = {
94
- enabled: false,
95
- minDurationSeconds: 10,
96
- sound: "silent",
97
- showPopup: false,
98
- sounds: [
99
- { alias: "silent" },
100
- { alias: "random" },
101
- { alias: "Funk", path: "/System/Library/Sounds/Funk.aiff" },
102
- { alias: "Glass", path: "/System/Library/Sounds/Glass.aiff" },
103
- { alias: "Hero", path: "/System/Library/Sounds/Hero.aiff" },
104
- { alias: "Submarine", path: "/System/Library/Sounds/Submarine.aiff" },
105
- ],
106
- volume: {
107
- mode: "timeScaled",
108
- max: 1.0,
109
- min: 0.1,
110
- },
111
- pushover: {
112
- enabled: false,
113
- userKey: "",
114
- apiToken: "",
115
- },
116
- };
117
-
118
- function loadConfig(): NotifyConfig {
119
- const configPath = getConfigPath();
120
-
121
- if (!existsSync(configPath)) {
122
- // First-run UX: create a usable default config so pi doesn't error on startup
123
- try {
124
- saveConfig(DEFAULT_CONFIG);
125
- } catch (err) {
126
- console.error(`Notify extension: failed to write default config to ${configPath}: ${err}`);
127
- }
128
- return DEFAULT_CONFIG;
129
- }
130
-
131
- let parsed: Partial<NotifyConfig> | undefined;
132
- try {
133
- const content = readFileSync(configPath, "utf-8");
134
- parsed = JSON.parse(content) as Partial<NotifyConfig>;
135
- } catch (err) {
136
- console.error(`Notify extension: failed to parse ${configPath}: ${err}`);
137
- return DEFAULT_CONFIG;
138
- }
139
-
140
- return {
141
- ...DEFAULT_CONFIG,
142
- ...parsed,
143
- volume: {
144
- ...DEFAULT_CONFIG.volume,
145
- ...(parsed.volume ?? {}),
146
- },
147
- pushover: {
148
- ...DEFAULT_CONFIG.pushover,
149
- ...(parsed.pushover ?? {}),
150
- },
151
- } as NotifyConfig;
152
- }
153
-
154
- function saveConfig(config: NotifyConfig): void {
155
- const configPath = getConfigPath();
156
- const dir = dirname(configPath);
157
-
158
- if (!existsSync(dir)) {
159
- mkdirSync(dir, { recursive: true });
160
- }
161
-
162
- writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
163
- }
164
-
165
- // =============================================================================
166
- // Volume Calculation
167
- // =============================================================================
168
-
169
- function calculateVolume(config: NotifyConfig, elapsedSeconds: number): number {
170
- if (config.volume.mode === "constant") {
171
- return config.volume.max;
172
- }
173
-
174
- // timeScaled mode: linear interpolation from min to max
175
- // At 1× threshold: min volume
176
- // At 4× threshold: max volume
177
- const threshold = config.minDurationSeconds;
178
- const minTime = threshold;
179
- const maxTime = threshold * 4;
180
-
181
- if (elapsedSeconds <= minTime) {
182
- return config.volume.min;
183
- }
184
- if (elapsedSeconds >= maxTime) {
185
- return config.volume.max;
186
- }
187
-
188
- // Linear interpolation
189
- const t = (elapsedSeconds - minTime) / (maxTime - minTime);
190
- return config.volume.min + t * (config.volume.max - config.volume.min);
191
- }
192
-
193
- // =============================================================================
194
- // Sound Playback
195
- // =============================================================================
196
-
197
- function findSound(config: NotifyConfig, alias: string): SoundEntry | undefined {
198
- return config.sounds.find((s) => s.alias.toLowerCase() === alias.toLowerCase());
199
- }
200
-
201
- function getPlayableSound(config: NotifyConfig, alias: string): SoundEntry | undefined {
202
- const lowerAlias = alias.toLowerCase();
203
-
204
- // Handle "random" - pick a random sound (excluding silent and random)
205
- if (lowerAlias === "random") {
206
- const playableSounds = config.sounds.filter(
207
- (s) => s.path && s.alias.toLowerCase() !== "silent" && s.alias.toLowerCase() !== "random"
208
- );
209
- if (playableSounds.length === 0) return undefined;
210
- return playableSounds[Math.floor(Math.random() * playableSounds.length)];
211
- }
212
-
213
- return findSound(config, alias);
214
- }
215
-
216
- function playSound(soundEntry: SoundEntry | undefined, volume: number): void {
217
- if (!soundEntry || !soundEntry.path) {
218
- // silent or not found
219
- return;
220
- }
221
-
222
- // Spawn detached so sound plays without blocking input
223
- const child = spawn("afplay", ["-v", String(volume), soundEntry.path], {
224
- detached: true,
225
- stdio: "ignore",
226
- });
227
- child.unref();
228
- }
229
-
230
- // =============================================================================
231
- // Pushover Integration
232
- // =============================================================================
233
-
234
- function sendPushover(config: NotifyConfig, title: string, message: string): void {
235
- if (!config.pushover.enabled || !config.pushover.userKey || !config.pushover.apiToken) {
236
- return;
237
- }
238
-
239
- // Spawn curl detached so it doesn't block
240
- const child = spawn("curl", [
241
- "-s",
242
- "-X", "POST",
243
- "https://api.pushover.net/1/messages.json",
244
- "--data-urlencode", `token=${config.pushover.apiToken}`,
245
- "--data-urlencode", `user=${config.pushover.userKey}`,
246
- "--data-urlencode", `title=${title}`,
247
- "--data-urlencode", `message=${message}`,
248
- ], {
249
- detached: true,
250
- stdio: "ignore",
251
- });
252
- child.unref();
253
- }
254
-
255
- // =============================================================================
256
- // Notification
257
- // =============================================================================
258
-
259
- function notify(title: string, body: string, config: NotifyConfig, elapsedSeconds: number): void {
260
- // Show popup notification if enabled
261
- if (config.showPopup) {
262
- try {
263
- execSync(`osascript -e 'display notification "${body}" with title "${title}"'`);
264
- } catch {
265
- // Silently fail
266
- }
267
- }
268
-
269
- // Play sound with calculated volume
270
- const soundEntry = getPlayableSound(config, config.sound);
271
- const volume = calculateVolume(config, elapsedSeconds);
272
- playSound(soundEntry, volume);
273
-
274
- // Send Pushover notification
275
- sendPushover(config, title, body);
276
- }
277
-
278
- // =============================================================================
279
- // Extension
280
- // =============================================================================
281
-
282
- export default function notifyExtension(pi: ExtensionAPI) {
283
- let config: NotifyConfig;
284
- let agentStartTime: number | null = null;
285
-
286
- // =========================================================================
287
- // Status Display
288
- // =========================================================================
289
-
290
- function updateStatus(ctx: ExtensionContext): void {
291
- if (!ctx.hasUI) return;
292
-
293
- if (config.enabled) {
294
- const lowerSound = config.sound.toLowerCase();
295
- const soundIndicator = lowerSound === "silent" ? "" : "♫";
296
- const popupIndicator = config.showPopup ? "↑" : "";
297
- const pushoverIndicator = config.pushover.enabled ? "⚡︎" : "";
298
- ctx.ui.setStatus(
299
- "notify",
300
- ctx.ui.theme.fg("success", `${soundIndicator}${popupIndicator}${pushoverIndicator} ${config.minDurationSeconds}s`)
301
- );
302
- } else {
303
- ctx.ui.setStatus("notify", ctx.ui.theme.fg("muted", ""));
304
- }
305
- }
306
-
307
- // =========================================================================
308
- // Toggle Functions
309
- // =========================================================================
310
-
311
- function toggleEnabled(ctx: ExtensionContext): void {
312
- config.enabled = !config.enabled;
313
- saveConfig(config);
314
-
315
- if (ctx.hasUI) {
316
- ctx.ui.notify(
317
- config.enabled
318
- ? `Notifications enabled (≥${config.minDurationSeconds}s)`
319
- : "Notifications disabled",
320
- "info"
321
- );
322
- }
323
-
324
- updateStatus(ctx);
325
- }
326
-
327
- function togglePopup(ctx: ExtensionContext): void {
328
- config.showPopup = !config.showPopup;
329
- saveConfig(config);
330
-
331
- if (ctx.hasUI) {
332
- ctx.ui.notify(
333
- config.showPopup ? "Popup notifications enabled" : "Popup notifications disabled",
334
- "info"
335
- );
336
- }
337
-
338
- updateStatus(ctx);
339
- }
340
-
341
- function togglePushover(ctx: ExtensionContext): void {
342
- config.pushover.enabled = !config.pushover.enabled;
343
- saveConfig(config);
344
-
345
- if (ctx.hasUI) {
346
- ctx.ui.notify(
347
- config.pushover.enabled ? "Pushover notifications enabled" : "Pushover notifications disabled",
348
- "info"
349
- );
350
- }
351
-
352
- updateStatus(ctx);
353
- }
354
-
355
- function toggleVolumeMode(ctx: ExtensionContext): void {
356
- config.volume.mode = config.volume.mode === "constant" ? "timeScaled" : "constant";
357
- saveConfig(config);
358
-
359
- if (ctx.hasUI) {
360
- ctx.ui.notify(
361
- config.volume.mode === "constant"
362
- ? `Volume mode: constant (${config.volume.max})`
363
- : `Volume mode: timeScaled (${config.volume.min} → ${config.volume.max})`,
364
- "info"
365
- );
366
- }
367
- }
368
-
369
- // =========================================================================
370
- // Hotkey Registration
371
- // =========================================================================
372
-
373
- pi.registerShortcut(TOGGLE_HOTKEY, {
374
- description: "Toggle notifications",
375
- handler: async (ctx) => {
376
- toggleEnabled(ctx);
377
- },
378
- });
379
-
380
- // =========================================================================
381
- // Command Registration
382
- // =========================================================================
383
-
384
- pi.registerCommand("notify", {
385
- description: "Configure desktop notifications",
386
- handler: async (args, ctx) => {
387
- // Quick subcommands
388
- if (args) {
389
- const arg = args.trim().toLowerCase();
390
-
391
- // /notify on
392
- if (arg === "on") {
393
- config.enabled = true;
394
- saveConfig(config);
395
- ctx.ui.notify("Notifications enabled", "info");
396
- updateStatus(ctx);
397
- return;
398
- }
399
-
400
- // /notify off
401
- if (arg === "off") {
402
- config.enabled = false;
403
- saveConfig(config);
404
- ctx.ui.notify("Notifications disabled", "info");
405
- updateStatus(ctx);
406
- return;
407
- }
408
-
409
- // /notify popup
410
- if (arg === "popup") {
411
- togglePopup(ctx);
412
- return;
413
- }
414
-
415
- // /notify pushover
416
- if (arg === "pushover") {
417
- togglePushover(ctx);
418
- return;
419
- }
420
-
421
- // /notify volume
422
- if (arg === "volume") {
423
- toggleVolumeMode(ctx);
424
- return;
425
- }
426
-
427
- // /notify <number> - set duration
428
- const num = parseInt(arg, 10);
429
- if (!isNaN(num) && num >= 0) {
430
- config.minDurationSeconds = num;
431
- saveConfig(config);
432
- ctx.ui.notify(`Notification threshold set to ${num} seconds`, "info");
433
- updateStatus(ctx);
434
- return;
435
- }
436
-
437
- // /notify <sound alias> - set sound (case-insensitive match)
438
- const matchedSound = findSound(config, arg);
439
- if (matchedSound) {
440
- config.sound = matchedSound.alias;
441
- saveConfig(config);
442
- if (matchedSound.path) {
443
- playSound(matchedSound, config.volume.max); // Preview at max volume
444
- }
445
- ctx.ui.notify(`Notification sound set to ${matchedSound.alias}`, "info");
446
- updateStatus(ctx);
447
- return;
448
- }
449
-
450
- // Unknown arg - show help
451
- ctx.ui.notify(
452
- `Unknown argument: ${args}\nUse: on, off, popup, pushover, volume, <seconds>, or <sound alias>`,
453
- "warning"
454
- );
455
- return;
456
- }
457
-
458
- // No args - show interactive menu
459
- const menuItems = [
460
- `${config.enabled ? "Disable" : "Enable"} notifications`,
461
- `${config.showPopup ? "Disable" : "Enable"} popup`,
462
- `${config.pushover.enabled ? "Disable" : "Enable"} Pushover (watch)`,
463
- `Volume mode: ${config.volume.mode} (tap to toggle)`,
464
- `Set max volume (current: ${config.volume.max})`,
465
- ...(config.volume.mode === "timeScaled" ? [`Set min volume (current: ${config.volume.min})`] : []),
466
- `Set duration threshold (current: ${config.minDurationSeconds}s)`,
467
- `Change sound (current: ${config.sound})`,
468
- "Test notification",
469
- ];
470
-
471
- const choice = await ctx.ui.select("Notification Settings", menuItems);
472
-
473
- if (choice === null) return;
474
-
475
- // Toggle notifications
476
- if (choice === menuItems[0]) {
477
- toggleEnabled(ctx);
478
- return;
479
- }
480
-
481
- // Toggle popup
482
- if (choice === menuItems[1]) {
483
- togglePopup(ctx);
484
- return;
485
- }
486
-
487
- // Toggle Pushover
488
- if (choice === menuItems[2]) {
489
- togglePushover(ctx);
490
- return;
491
- }
492
-
493
- // Toggle volume mode
494
- if (choice === menuItems[3]) {
495
- toggleVolumeMode(ctx);
496
- return;
497
- }
498
-
499
- // Set max volume
500
- if (choice === menuItems[4]) {
501
- const input = await ctx.ui.input("Max volume (0.0 - 1.0+)", String(config.volume.max));
502
- if (input !== null) {
503
- const vol = parseFloat(input);
504
- if (!isNaN(vol) && vol >= 0) {
505
- config.volume.max = vol;
506
- saveConfig(config);
507
- ctx.ui.notify(`Max volume set to ${vol}`, "info");
508
- } else {
509
- ctx.ui.notify("Invalid volume", "error");
510
- }
511
- }
512
- return;
513
- }
514
-
515
- // Set min volume (only in timeScaled mode)
516
- if (config.volume.mode === "timeScaled" && choice === menuItems[5]) {
517
- const input = await ctx.ui.input("Min volume (0.0 - 1.0+)", String(config.volume.min));
518
- if (input !== null) {
519
- const vol = parseFloat(input);
520
- if (!isNaN(vol) && vol >= 0) {
521
- config.volume.min = vol;
522
- saveConfig(config);
523
- ctx.ui.notify(`Min volume set to ${vol}`, "info");
524
- } else {
525
- ctx.ui.notify("Invalid volume", "error");
526
- }
527
- }
528
- return;
529
- }
530
-
531
- // Set duration - index shifts based on whether min volume is shown
532
- const durationIndex = config.volume.mode === "timeScaled" ? 6 : 5;
533
- if (choice === menuItems[durationIndex]) {
534
- const input = await ctx.ui.input(
535
- "Minimum duration (seconds)",
536
- String(config.minDurationSeconds)
537
- );
538
- if (input !== null) {
539
- const num = parseInt(input, 10);
540
- if (!isNaN(num) && num >= 0) {
541
- config.minDurationSeconds = num;
542
- saveConfig(config);
543
- ctx.ui.notify(`Threshold set to ${num} seconds`, "info");
544
- updateStatus(ctx);
545
- } else {
546
- ctx.ui.notify("Invalid number", "error");
547
- }
548
- }
549
- return;
550
- }
551
-
552
- // Change sound
553
- const soundIndex = config.volume.mode === "timeScaled" ? 7 : 6;
554
- if (choice === menuItems[soundIndex]) {
555
- const soundAliases = config.sounds.map((s) => s.alias);
556
- const soundChoice = await ctx.ui.select("Select sound", soundAliases);
557
- if (soundChoice !== null) {
558
- config.sound = soundChoice;
559
- saveConfig(config);
560
- const soundEntry = findSound(config, soundChoice);
561
- if (soundEntry?.path) {
562
- playSound(soundEntry, config.volume.max); // Preview at max volume
563
- }
564
- ctx.ui.notify(`Sound set to ${soundChoice}`, "info");
565
- updateStatus(ctx);
566
- }
567
- return;
568
- }
569
-
570
- // Test notification
571
- const testIndex = config.volume.mode === "timeScaled" ? 8 : 7;
572
- if (choice === menuItems[testIndex]) {
573
- // Test at 4x threshold to demonstrate max volume
574
- notify("𝞹", "⟳", config, config.minDurationSeconds * 4);
575
- return;
576
- }
577
- },
578
- });
579
-
580
- // =========================================================================
581
- // Agent Lifecycle Events
582
- // =========================================================================
583
-
584
- pi.on("agent_start", async () => {
585
- agentStartTime = Date.now();
586
- });
587
-
588
- pi.on("agent_end", async () => {
589
- if (!config.enabled || agentStartTime === null) {
590
- agentStartTime = null;
591
- return;
592
- }
593
-
594
- const elapsedSeconds = (Date.now() - agentStartTime) / 1000;
595
- agentStartTime = null;
596
-
597
- if (elapsedSeconds >= config.minDurationSeconds) {
598
- notify("𝞹", "⟳", config, elapsedSeconds);
599
- }
600
- });
601
-
602
- // =========================================================================
603
- // Session Initialization
604
- // =========================================================================
605
-
606
- pi.on("session_start", async (_event, ctx) => {
607
- config = loadConfig();
608
- updateStatus(ctx);
609
- });
610
- }
@@ -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
- }