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 +1 -1
- package/README.md +33 -43
- package/index.ts +55 -0
- package/package.json +17 -35
- package/extensions/notify/index.ts +0 -583
- package/extensions/notify/notify.json.example +0 -52
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,64 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-notify
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
+
## Install
|
|
13
19
|
|
|
14
20
|
```bash
|
|
15
21
|
pi install npm:pi-notify
|
|
16
22
|
```
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
30
|
+
Restart Pi.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
37
33
|
|
|
38
|
-
|
|
34
|
+
When Pi's agent finishes (`agent_end` event), the extension sends a notification via the appropriate protocol:
|
|
39
35
|
|
|
40
|
-
-
|
|
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
|
-
|
|
40
|
+
Clicking the notification focuses the terminal window/tab.
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
## What's OSC 777/99?
|
|
45
43
|
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
- Shortcut: `Alt+N` (toggle on/off)
|
|
48
|
+
## Known Limitations
|
|
52
49
|
|
|
53
|
-
|
|
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
|
-
##
|
|
52
|
+
## License
|
|
62
53
|
|
|
63
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|