pi-notify 0.1.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 +21 -0
- package/README.md +64 -0
- package/extensions/notify/index.ts +583 -0
- package/extensions/notify/notify.json.example +52 -0
- package/package.json +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Warren Winter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Notifications for Pi (`pi-notify`)
|
|
2
|
+
|
|
3
|
+
Sends notifications when an agent turn finishes and took longer than a configurable threshold.
|
|
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)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
From npm:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:pi-notify
|
|
16
|
+
```
|
|
17
|
+
|
|
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
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
Create your config file:
|
|
39
|
+
|
|
40
|
+
- copy `notify.json.example` → `notify.json`
|
|
41
|
+
|
|
42
|
+
Location:
|
|
43
|
+
|
|
44
|
+
- `~/.pi/agent/extensions/notify/notify.json`
|
|
45
|
+
|
|
46
|
+
(If the config file is missing, the extension will error with instructions.)
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
- Command: `/notify`
|
|
51
|
+
- Shortcut: `Alt+N` (toggle on/off)
|
|
52
|
+
|
|
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)
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- macOS-only out of the box (uses `osascript` + `afplay`)
|
|
64
|
+
- Pushover requires `curl` and valid `userKey` + `apiToken` in config
|
|
@@ -0,0 +1,583 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
37
|
+
}
|