@pi-unipi/notify 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -74
- package/commands.ts +8 -6
- package/events.ts +38 -25
- package/index.ts +2 -1
- package/ntfy-config.ts +178 -0
- package/package.json +2 -1
- package/skills/configure-notify/SKILL.md +43 -6
- package/tools.ts +6 -4
- package/tui/ntfy-setup.ts +68 -15
- package/tui/settings-overlay.ts +21 -6
package/README.md
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
# @pi-unipi/notify
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Push notifications when things happen. Workflow finishes, Ralph loop completes, MCP server errors — notify sends alerts to native OS, Gotify, Telegram, or ntfy.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
| Command | Description |
|
|
10
|
+
|---------|-------------|
|
|
11
|
+
| `/unipi:notify-settings` | Open settings overlay to configure platforms and events |
|
|
12
|
+
| `/unipi:notify-set-gotify` | Configure Gotify server connection |
|
|
13
|
+
| `/unipi:notify-set-tg` | Interactive Telegram bot setup |
|
|
14
|
+
| `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
|
|
15
|
+
| `/unipi:notify-recap-model` | Set model for notification recaps |
|
|
16
|
+
| `/unipi:notify-test` | Send test notification to all enabled platforms |
|
|
11
17
|
|
|
12
|
-
##
|
|
18
|
+
## Special Triggers
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
Notify subscribes to Pi lifecycle events and routes notifications based on your config:
|
|
21
|
+
|
|
22
|
+
| Event | Default | Description |
|
|
23
|
+
|-------|---------|-------------|
|
|
24
|
+
| `workflow_end` | On | Workflow command completes |
|
|
25
|
+
| `ralph_loop_end` | On | Ralph loop completes |
|
|
26
|
+
| `mcp_server_error` | On | MCP server error |
|
|
27
|
+
| `agent_end` | Off | Agent finishes responding |
|
|
28
|
+
| `memory_consolidated` | Off | Memory auto-saved |
|
|
29
|
+
| `session_shutdown` | Off | Session ends |
|
|
30
|
+
|
|
31
|
+
Notify registers with the info-screen dashboard, showing enabled platforms and last notification time. The footer subscribes to `NOTIFICATION_SENT` events to display notification stats.
|
|
32
|
+
|
|
33
|
+
## Agent Tool
|
|
34
|
+
|
|
35
|
+
| Tool | Description |
|
|
36
|
+
|------|-------------|
|
|
37
|
+
| `notify_user` | Send cross-platform notification |
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
notify_user({
|
|
41
|
+
title: "Build Failed",
|
|
42
|
+
message: "TypeScript compilation failed with 12 errors.",
|
|
43
|
+
priority: "high"
|
|
44
|
+
})
|
|
45
|
+
```
|
|
15
46
|
|
|
16
47
|
## Platforms
|
|
17
48
|
|
|
18
|
-
### Native OS
|
|
49
|
+
### Native OS
|
|
19
50
|
|
|
20
51
|
Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notifier):
|
|
21
52
|
- **Windows:** SnoreToast (no admin required)
|
|
@@ -26,7 +57,7 @@ Zero configuration — works out of the box.
|
|
|
26
57
|
|
|
27
58
|
### Gotify
|
|
28
59
|
|
|
29
|
-
Self-hosted push notification server
|
|
60
|
+
Self-hosted push notification server:
|
|
30
61
|
|
|
31
62
|
```json
|
|
32
63
|
{
|
|
@@ -41,26 +72,14 @@ Self-hosted push notification server. Configure in settings:
|
|
|
41
72
|
|
|
42
73
|
### Telegram
|
|
43
74
|
|
|
44
|
-
Bot API notifications. Run setup
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
This guides you through:
|
|
51
|
-
1. Creating a bot via @BotFather
|
|
52
|
-
2. Pasting the bot token
|
|
53
|
-
3. Auto-detecting your chat ID
|
|
75
|
+
Bot API notifications. Run `/unipi:notify-set-tg` for interactive setup:
|
|
76
|
+
1. Create a bot via @BotFather
|
|
77
|
+
2. Paste the bot token
|
|
78
|
+
3. Auto-detect your chat ID
|
|
54
79
|
|
|
55
80
|
### ntfy
|
|
56
81
|
|
|
57
|
-
HTTP-based pub-sub notifications via [ntfy.sh](https://ntfy.sh) or self-hosted
|
|
58
|
-
|
|
59
|
-
```
|
|
60
|
-
/unipi:notify-set-ntfy
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Or configure manually:
|
|
82
|
+
HTTP-based pub-sub notifications via [ntfy.sh](https://ntfy.sh) or self-hosted:
|
|
64
83
|
|
|
65
84
|
```json
|
|
66
85
|
{
|
|
@@ -73,54 +92,12 @@ Or configure manually:
|
|
|
73
92
|
}
|
|
74
93
|
```
|
|
75
94
|
|
|
76
|
-
##
|
|
77
|
-
|
|
78
|
-
| Command | Description |
|
|
79
|
-
|---------|-------------|
|
|
80
|
-
| `/unipi:notify-settings` | Open settings overlay to configure platforms and events |
|
|
81
|
-
| `/unipi:notify-set-gotify` | Configure Gotify server connection |
|
|
82
|
-
| `/unipi:notify-set-tg` | Interactive Telegram bot setup |
|
|
83
|
-
| `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
|
|
84
|
-
| `/unipi:notify-test` | Send test notification to all enabled platforms |
|
|
85
|
-
|
|
86
|
-
## Agent Tool
|
|
87
|
-
|
|
88
|
-
The `notify_user` tool is available to the agent for ad-hoc notifications:
|
|
89
|
-
|
|
90
|
-
```
|
|
91
|
-
notify_user({
|
|
92
|
-
title: "Build Failed",
|
|
93
|
-
message: "TypeScript compilation failed with 12 errors.",
|
|
94
|
-
priority: "high"
|
|
95
|
-
})
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
See the bundled `notify` skill for full parameter documentation.
|
|
99
|
-
|
|
100
|
-
## Event Configuration
|
|
101
|
-
|
|
102
|
-
Notifications are triggered by these events (configurable in settings):
|
|
103
|
-
|
|
104
|
-
| Event | Default | Description |
|
|
105
|
-
|-------|---------|-------------|
|
|
106
|
-
| `workflow_end` | On | Workflow command completes |
|
|
107
|
-
| `ralph_loop_end` | On | Ralph loop completes |
|
|
108
|
-
| `mcp_server_error` | On | MCP server error |
|
|
109
|
-
| `agent_end` | Off | Agent finishes responding |
|
|
110
|
-
| `memory_consolidated` | Off | Memory auto-saved |
|
|
111
|
-
| `session_shutdown` | Off | Session ends |
|
|
95
|
+
## Configurables
|
|
112
96
|
|
|
113
|
-
|
|
97
|
+
Settings stored at `~/.unipi/config/notify/config.json`. Edit via `/unipi:notify-settings` or manual JSON editing.
|
|
114
98
|
|
|
115
|
-
|
|
116
|
-
- Settings overlay: `/unipi:notify-settings`
|
|
117
|
-
- Manual JSON editing
|
|
118
|
-
- The agent can read config via the settings module
|
|
99
|
+
Per-event platform routing lets you control where each event type goes. The settings overlay shows all events with platform toggles.
|
|
119
100
|
|
|
120
|
-
##
|
|
101
|
+
## License
|
|
121
102
|
|
|
122
|
-
|
|
123
|
-
- Enabled platform count
|
|
124
|
-
- Active event subscriptions
|
|
125
|
-
- Last notification timestamp
|
|
126
|
-
- Total notifications sent this session
|
|
103
|
+
MIT
|
package/commands.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { TelegramSetupOverlay } from "./tui/telegram-setup.js";
|
|
|
13
13
|
import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
|
|
14
14
|
import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
|
|
15
15
|
import { loadConfig } from "./settings.js";
|
|
16
|
+
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
16
17
|
import { sendNativeNotification } from "./platforms/native.js";
|
|
17
18
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
18
19
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
@@ -310,16 +311,17 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
310
311
|
}
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
// ntfy
|
|
314
|
-
|
|
314
|
+
// ntfy — resolved from project/global ntfy.json
|
|
315
|
+
const ntfyConfig = loadNtfyConfig(process.cwd());
|
|
316
|
+
if (ntfyConfig.enabled && ntfyConfig.serverUrl && ntfyConfig.topic) {
|
|
315
317
|
try {
|
|
316
318
|
await sendNtfyNotification(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
+
ntfyConfig.serverUrl,
|
|
320
|
+
ntfyConfig.topic,
|
|
319
321
|
title,
|
|
320
322
|
message,
|
|
321
|
-
|
|
322
|
-
|
|
323
|
+
ntfyConfig.priority,
|
|
324
|
+
ntfyConfig.token
|
|
323
325
|
);
|
|
324
326
|
results.push("✓ ntfy: sent");
|
|
325
327
|
} catch (err) {
|
package/events.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
9
9
|
import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
10
10
|
import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
|
|
11
|
+
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
11
12
|
import { sendNativeNotification } from "./platforms/native.js";
|
|
12
13
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
13
14
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
@@ -47,7 +48,8 @@ export const BUILTIN_EVENTS: Record<
|
|
|
47
48
|
*/
|
|
48
49
|
export function registerEventListeners(
|
|
49
50
|
pi: ExtensionAPI,
|
|
50
|
-
config: NotifyConfig
|
|
51
|
+
config: NotifyConfig,
|
|
52
|
+
cwd: string
|
|
51
53
|
): void {
|
|
52
54
|
// Register built-in events (except agent_end which has custom logic)
|
|
53
55
|
for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
|
|
@@ -60,8 +62,10 @@ export function registerEventListeners(
|
|
|
60
62
|
const title = `Pi — ${def.label}`;
|
|
61
63
|
const message = buildEventMessage(eventKey, payload);
|
|
62
64
|
// Fire-and-forget: don't block the event emitter
|
|
63
|
-
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config).catch(
|
|
64
|
-
(
|
|
65
|
+
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
|
|
66
|
+
() => {
|
|
67
|
+
// Silently ignore — background notification failure is non-blocking.
|
|
68
|
+
}
|
|
65
69
|
);
|
|
66
70
|
};
|
|
67
71
|
|
|
@@ -96,9 +100,11 @@ export function registerEventListeners(
|
|
|
96
100
|
})
|
|
97
101
|
.catch(() => buildAgentEndMessage(sessionName))
|
|
98
102
|
.then((message) =>
|
|
99
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config)
|
|
103
|
+
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd)
|
|
100
104
|
)
|
|
101
|
-
.catch((
|
|
105
|
+
.catch(() => {
|
|
106
|
+
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
107
|
+
});
|
|
102
108
|
return;
|
|
103
109
|
}
|
|
104
110
|
}
|
|
@@ -106,8 +112,10 @@ export function registerEventListeners(
|
|
|
106
112
|
|
|
107
113
|
// No recap or recap unavailable: dispatch immediately in background
|
|
108
114
|
const message = buildAgentEndMessage(sessionName);
|
|
109
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config).catch(
|
|
110
|
-
(
|
|
115
|
+
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd).catch(
|
|
116
|
+
() => {
|
|
117
|
+
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
118
|
+
}
|
|
111
119
|
);
|
|
112
120
|
};
|
|
113
121
|
|
|
@@ -126,12 +134,12 @@ export function registerEventListeners(
|
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
/** Get all platforms that are currently enabled in config */
|
|
129
|
-
function getEnabledPlatforms(config: NotifyConfig): NotifyPlatform[] {
|
|
137
|
+
function getEnabledPlatforms(config: NotifyConfig, ntfyEnabled: boolean): NotifyPlatform[] {
|
|
130
138
|
const enabled: NotifyPlatform[] = [];
|
|
131
139
|
if (config.native.enabled) enabled.push("native");
|
|
132
140
|
if (config.gotify.enabled) enabled.push("gotify");
|
|
133
141
|
if (config.telegram.enabled) enabled.push("telegram");
|
|
134
|
-
if (
|
|
142
|
+
if (ntfyEnabled) enabled.push("ntfy");
|
|
135
143
|
return enabled;
|
|
136
144
|
}
|
|
137
145
|
|
|
@@ -148,34 +156,35 @@ export async function dispatchNotification(
|
|
|
148
156
|
message: string,
|
|
149
157
|
eventPlatforms: NotifyPlatform[],
|
|
150
158
|
eventType: string,
|
|
151
|
-
config: NotifyConfig
|
|
159
|
+
config: NotifyConfig,
|
|
160
|
+
cwd: string
|
|
152
161
|
): Promise<NotifyDispatchResult> {
|
|
162
|
+
// Resolve ntfy config from project/global ntfy.json
|
|
163
|
+
const ntfyConfig = loadNtfyConfig(cwd);
|
|
164
|
+
|
|
153
165
|
// Resolve platforms: event-specific → all enabled → global defaults
|
|
154
166
|
const platforms =
|
|
155
167
|
eventPlatforms.length > 0
|
|
156
168
|
? eventPlatforms
|
|
157
|
-
: getEnabledPlatforms(config).length > 0
|
|
158
|
-
? getEnabledPlatforms(config)
|
|
169
|
+
: getEnabledPlatforms(config, ntfyConfig.enabled).length > 0
|
|
170
|
+
? getEnabledPlatforms(config, ntfyConfig.enabled)
|
|
159
171
|
: config.defaultPlatforms;
|
|
160
172
|
|
|
161
173
|
const enabledPlatforms = platforms.filter((p) => {
|
|
162
174
|
if (p === "native") return config.native.enabled;
|
|
163
175
|
if (p === "gotify") return config.gotify.enabled;
|
|
164
176
|
if (p === "telegram") return config.telegram.enabled;
|
|
165
|
-
if (p === "ntfy") return
|
|
177
|
+
if (p === "ntfy") return ntfyConfig.enabled;
|
|
166
178
|
return false;
|
|
167
179
|
});
|
|
168
180
|
|
|
169
181
|
const results = await Promise.all(
|
|
170
182
|
enabledPlatforms.map(async (platform) => {
|
|
171
183
|
try {
|
|
172
|
-
await sendToPlatform(platform, title, message, config);
|
|
184
|
+
await sendToPlatform(platform, title, message, config, cwd);
|
|
173
185
|
return { platform, success: true };
|
|
174
186
|
} catch (err) {
|
|
175
|
-
|
|
176
|
-
`[notify] Failed to send via ${platform}:`,
|
|
177
|
-
err instanceof Error ? err.message : err
|
|
178
|
-
);
|
|
187
|
+
// Silently ignore — platform send failure is tracked in results.
|
|
179
188
|
return {
|
|
180
189
|
platform,
|
|
181
190
|
success: false,
|
|
@@ -203,7 +212,8 @@ async function sendToPlatform(
|
|
|
203
212
|
platform: NotifyPlatform,
|
|
204
213
|
title: string,
|
|
205
214
|
message: string,
|
|
206
|
-
config: NotifyConfig
|
|
215
|
+
config: NotifyConfig,
|
|
216
|
+
cwd: string
|
|
207
217
|
): Promise<void> {
|
|
208
218
|
switch (platform) {
|
|
209
219
|
case "native":
|
|
@@ -234,19 +244,22 @@ async function sendToPlatform(
|
|
|
234
244
|
message
|
|
235
245
|
);
|
|
236
246
|
break;
|
|
237
|
-
case "ntfy":
|
|
238
|
-
|
|
247
|
+
case "ntfy": {
|
|
248
|
+
const ntfyConfig = loadNtfyConfig(cwd);
|
|
249
|
+
if (!ntfyConfig.enabled) return;
|
|
250
|
+
if (!ntfyConfig.serverUrl || !ntfyConfig.topic) {
|
|
239
251
|
throw new Error("ntfy: serverUrl and topic are required");
|
|
240
252
|
}
|
|
241
253
|
await sendNtfyNotification(
|
|
242
|
-
|
|
243
|
-
|
|
254
|
+
ntfyConfig.serverUrl,
|
|
255
|
+
ntfyConfig.topic,
|
|
244
256
|
title,
|
|
245
257
|
message,
|
|
246
|
-
|
|
247
|
-
|
|
258
|
+
ntfyConfig.priority,
|
|
259
|
+
ntfyConfig.token
|
|
248
260
|
);
|
|
249
261
|
break;
|
|
262
|
+
}
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|
package/index.ts
CHANGED
|
@@ -42,8 +42,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
42
42
|
// Session lifecycle — register events and announce module
|
|
43
43
|
pi.on("session_start", async (_event, ctx) => {
|
|
44
44
|
setSessionContext(ctx);
|
|
45
|
+
const cwd = process.cwd();
|
|
45
46
|
const config = loadConfig();
|
|
46
|
-
registerEventListeners(pi, config);
|
|
47
|
+
registerEventListeners(pi, config, cwd);
|
|
47
48
|
|
|
48
49
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
49
50
|
name: MODULES.NOTIFY,
|
package/ntfy-config.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Project-level ntfy configuration
|
|
3
|
+
*
|
|
4
|
+
* Loads, saves, and resolves ntfy config from dedicated ntfy.json files
|
|
5
|
+
* at global (~/.unipi/config/notify/ntfy.json) and project
|
|
6
|
+
* (<cwd>/.unipi/config/notify/ntfy.json) scope.
|
|
7
|
+
*
|
|
8
|
+
* Resolution: project → global → defaults.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import type { NtfyConfig } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/** Default ntfy configuration */
|
|
17
|
+
const DEFAULT_NTFY_CONFIG: NtfyConfig = {
|
|
18
|
+
enabled: false,
|
|
19
|
+
serverUrl: "https://ntfy.sh",
|
|
20
|
+
priority: 3,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Global ntfy.json path: ~/.unipi/config/notify/ntfy.json */
|
|
24
|
+
function getGlobalNtfyPath(): string {
|
|
25
|
+
return join(homedir(), ".unipi", "config", "notify", "ntfy.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Project ntfy.json path: <cwd>/.unipi/config/notify/ntfy.json */
|
|
29
|
+
function getProjectNtfyPath(cwd: string): string {
|
|
30
|
+
return join(cwd, ".unipi", "config", "notify", "ntfy.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Read and parse a ntfy.json file.
|
|
35
|
+
* Returns null if file doesn't exist (ENOENT).
|
|
36
|
+
* Returns null and logs warning on parse error.
|
|
37
|
+
*/
|
|
38
|
+
function readNtfyJson(filePath: string): NtfyConfig | null {
|
|
39
|
+
try {
|
|
40
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
41
|
+
const parsed = JSON.parse(raw) as Partial<NtfyConfig>;
|
|
42
|
+
return { ...DEFAULT_NTFY_CONFIG, ...parsed };
|
|
43
|
+
} catch (err: unknown) {
|
|
44
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
console.warn(`[notify] Failed to parse ${filePath}: ${(err as Error).message}. Falling back.`);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve ntfy config with project → global → defaults priority.
|
|
54
|
+
*
|
|
55
|
+
* 1. If project ntfy.json exists → use it
|
|
56
|
+
* 2. If only global ntfy.json exists → use it
|
|
57
|
+
* 3. If neither exists → return defaults (ntfy disabled)
|
|
58
|
+
*
|
|
59
|
+
* Runs legacy migration once if global ntfy.json is missing.
|
|
60
|
+
*/
|
|
61
|
+
export function loadNtfyConfig(cwd: string): NtfyConfig {
|
|
62
|
+
// Attempt migration from legacy config.json if global ntfy.json doesn't exist
|
|
63
|
+
migrateFromLegacyConfig();
|
|
64
|
+
|
|
65
|
+
// Try project-level first
|
|
66
|
+
const projectConfig = readNtfyJson(getProjectNtfyPath(cwd));
|
|
67
|
+
if (projectConfig !== null) {
|
|
68
|
+
return projectConfig;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Try global level
|
|
72
|
+
const globalConfig = readNtfyJson(getGlobalNtfyPath());
|
|
73
|
+
if (globalConfig !== null) {
|
|
74
|
+
return globalConfig;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Neither exists — return defaults
|
|
78
|
+
return { ...DEFAULT_NTFY_CONFIG };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Save ntfy config to the chosen scope.
|
|
83
|
+
* Creates parent directory if needed.
|
|
84
|
+
*/
|
|
85
|
+
export function saveNtfyConfig(
|
|
86
|
+
scope: "project" | "global",
|
|
87
|
+
cwd: string,
|
|
88
|
+
config: NtfyConfig
|
|
89
|
+
): void {
|
|
90
|
+
const filePath =
|
|
91
|
+
scope === "project" ? getProjectNtfyPath(cwd) : getGlobalNtfyPath();
|
|
92
|
+
const dir = dirname(filePath);
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect which scope is currently active for ntfy config.
|
|
99
|
+
*
|
|
100
|
+
* - "project" if project ntfy.json exists
|
|
101
|
+
* - "global" if global ntfy.json exists (and no project override)
|
|
102
|
+
* - "none" if neither exists
|
|
103
|
+
*/
|
|
104
|
+
export function getNtfyConfigScope(
|
|
105
|
+
cwd: string
|
|
106
|
+
): "project" | "global" | "none" {
|
|
107
|
+
if (existsSync(getProjectNtfyPath(cwd))) {
|
|
108
|
+
return "project";
|
|
109
|
+
}
|
|
110
|
+
if (existsSync(getGlobalNtfyPath())) {
|
|
111
|
+
return "global";
|
|
112
|
+
}
|
|
113
|
+
return "none";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* One-time migration from legacy config.json ntfy section to ntfy.json.
|
|
118
|
+
*
|
|
119
|
+
* Trigger conditions:
|
|
120
|
+
* - Global ntfy.json does NOT exist
|
|
121
|
+
* - config.json has non-default ntfy settings (enabled or topic/serverUrl set)
|
|
122
|
+
*
|
|
123
|
+
* After migration, config.json ntfy section is left untouched.
|
|
124
|
+
* Future reads use ntfy.json exclusively.
|
|
125
|
+
*/
|
|
126
|
+
export function migrateFromLegacyConfig(): void {
|
|
127
|
+
const globalNtfyPath = getGlobalNtfyPath();
|
|
128
|
+
|
|
129
|
+
// Only migrate if global ntfy.json doesn't exist yet
|
|
130
|
+
if (existsSync(globalNtfyPath)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Try to read legacy config.json
|
|
135
|
+
const legacyConfigPath = join(
|
|
136
|
+
homedir(),
|
|
137
|
+
".unipi",
|
|
138
|
+
"config",
|
|
139
|
+
"notify",
|
|
140
|
+
"config.json"
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
if (!existsSync(legacyConfigPath)) return;
|
|
145
|
+
const raw = readFileSync(legacyConfigPath, "utf-8");
|
|
146
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
147
|
+
const ntfySection = parsed.ntfy as Partial<NtfyConfig> | undefined;
|
|
148
|
+
|
|
149
|
+
if (!ntfySection) return;
|
|
150
|
+
|
|
151
|
+
// Only migrate if there's something meaningful to migrate
|
|
152
|
+
const hasCustomConfig =
|
|
153
|
+
ntfySection.enabled === true ||
|
|
154
|
+
(ntfySection.serverUrl && ntfySection.serverUrl !== "https://ntfy.sh") ||
|
|
155
|
+
ntfySection.topic;
|
|
156
|
+
|
|
157
|
+
if (!hasCustomConfig) return;
|
|
158
|
+
|
|
159
|
+
// Write ntfy.json from legacy config
|
|
160
|
+
const migratedConfig: NtfyConfig = {
|
|
161
|
+
enabled: ntfySection.enabled ?? false,
|
|
162
|
+
serverUrl: ntfySection.serverUrl ?? "https://ntfy.sh",
|
|
163
|
+
topic: ntfySection.topic,
|
|
164
|
+
token: ntfySection.token,
|
|
165
|
+
priority: ntfySection.priority ?? 3,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const dir = dirname(globalNtfyPath);
|
|
169
|
+
mkdirSync(dir, { recursive: true });
|
|
170
|
+
writeFileSync(
|
|
171
|
+
globalNtfyPath,
|
|
172
|
+
JSON.stringify(migratedConfig, null, 2) + "\n",
|
|
173
|
+
"utf-8"
|
|
174
|
+
);
|
|
175
|
+
} catch {
|
|
176
|
+
// Migration failure is non-fatal — silently continue
|
|
177
|
+
}
|
|
178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"commands.ts",
|
|
25
25
|
"settings.ts",
|
|
26
26
|
"events.ts",
|
|
27
|
+
"ntfy-config.ts",
|
|
27
28
|
"summarize.ts",
|
|
28
29
|
"types.ts",
|
|
29
30
|
"platforms/*",
|
|
@@ -16,9 +16,13 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
16
16
|
- User wants to change which events trigger notifications
|
|
17
17
|
- User asks about notification settings
|
|
18
18
|
|
|
19
|
-
## Config
|
|
19
|
+
## Config locations
|
|
20
20
|
|
|
21
|
-
`~/.unipi/config/notify/config.json`
|
|
21
|
+
**Main config (platforms + events):** `~/.unipi/config/notify/config.json`
|
|
22
|
+
|
|
23
|
+
**ntfy config (dedicated file):**
|
|
24
|
+
- Global: `~/.unipi/config/notify/ntfy.json`
|
|
25
|
+
- Project: `<project>/.unipi/config/notify/ntfy.json`
|
|
22
26
|
|
|
23
27
|
## Config structure
|
|
24
28
|
|
|
@@ -54,7 +58,8 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
54
58
|
"topic": null,
|
|
55
59
|
"token": null,
|
|
56
60
|
"priority": 3
|
|
57
|
-
}
|
|
61
|
+
},
|
|
62
|
+
"NOTE": "ntfy section is legacy — migrated to ntfy.json on first run"
|
|
58
63
|
}
|
|
59
64
|
```
|
|
60
65
|
|
|
@@ -92,9 +97,41 @@ Requires:
|
|
|
92
97
|
- `priority` — 1-5 (default: 3)
|
|
93
98
|
|
|
94
99
|
**Setup options:**
|
|
95
|
-
1. **Interactive overlay:** Run `/unipi:notify-set-ntfy` for guided setup with connection test
|
|
96
|
-
2. **Manual config:** Edit `
|
|
97
|
-
3. **Agent can write config:** Read the current
|
|
100
|
+
1. **Interactive overlay:** Run `/unipi:notify-set-ntfy` for guided setup with scope selection and connection test
|
|
101
|
+
2. **Manual config:** Edit `ntfy.json` directly (see Project-Level ntfy Config below)
|
|
102
|
+
3. **Agent can write config:** Read the current ntfy.json, merge changes, write back
|
|
103
|
+
|
|
104
|
+
### Project-Level ntfy Config
|
|
105
|
+
|
|
106
|
+
ntfy uses dedicated `ntfy.json` files at both global and project scope, with full override semantics.
|
|
107
|
+
|
|
108
|
+
**File locations:**
|
|
109
|
+
- Global: `~/.unipi/config/notify/ntfy.json` (all projects)
|
|
110
|
+
- Project: `<project>/.unipi/config/notify/ntfy.json` (this project only)
|
|
111
|
+
|
|
112
|
+
**Resolution order (at dispatch time):**
|
|
113
|
+
1. Project `ntfy.json` exists → use it (full override)
|
|
114
|
+
2. No project config → use global `ntfy.json`
|
|
115
|
+
3. Neither exists → ntfy is effectively disabled
|
|
116
|
+
|
|
117
|
+
**ntfy.json shape:**
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"enabled": true,
|
|
121
|
+
"serverUrl": "https://ntfy.sh",
|
|
122
|
+
"topic": "my-project-alerts",
|
|
123
|
+
"token": null,
|
|
124
|
+
"priority": 3
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**Scope selection in wizard:** When running `/unipi:notify-set-ntfy`, the wizard now asks where to save the config (Global or Project). Re-running the wizard pre-selects the current scope and pre-fills existing values.
|
|
129
|
+
|
|
130
|
+
**Settings overlay:** The ntfy line in `/unipi:notify-settings` shows topic, priority, and scope label (`[project]`, `[global]`, or "Not configured").
|
|
131
|
+
|
|
132
|
+
**Migration:** On first run, if `config.json` has ntfy settings and `ntfy.json` doesn't exist, settings are automatically migrated to `ntfy.json`. The legacy `config.json` ntfy section is left untouched for backward compatibility.
|
|
133
|
+
|
|
134
|
+
**Manual config:** Edit the appropriate `ntfy.json` file directly with the fields above.
|
|
98
135
|
|
|
99
136
|
## Commands
|
|
100
137
|
|
package/tools.ts
CHANGED
|
@@ -64,16 +64,18 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
|
|
|
64
64
|
const notifPlatforms = platforms || config.defaultPlatforms;
|
|
65
65
|
|
|
66
66
|
// Fire-and-forget: dispatch in background so the tool doesn't block the agent
|
|
67
|
+
const cwd = process.cwd();
|
|
67
68
|
dispatchNotification(
|
|
68
69
|
pi,
|
|
69
70
|
notifTitle,
|
|
70
71
|
message,
|
|
71
72
|
notifPlatforms,
|
|
72
73
|
"agent_tool",
|
|
73
|
-
config
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
config,
|
|
75
|
+
cwd
|
|
76
|
+
).catch(() => {
|
|
77
|
+
// Silently ignore — background dispatch failure is non-blocking.
|
|
78
|
+
});
|
|
77
79
|
|
|
78
80
|
return {
|
|
79
81
|
content: [
|
package/tui/ntfy-setup.ts
CHANGED
|
@@ -10,10 +10,11 @@ import type { Component } from "@mariozechner/pi-tui";
|
|
|
10
10
|
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
11
11
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
12
12
|
import { sendNtfyNotification } from "../platforms/ntfy.js";
|
|
13
|
-
import {
|
|
13
|
+
import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
|
|
14
14
|
|
|
15
15
|
type SetupPhase =
|
|
16
16
|
| "instructions"
|
|
17
|
+
| "scope"
|
|
17
18
|
| "server-url"
|
|
18
19
|
| "topic"
|
|
19
20
|
| "token"
|
|
@@ -28,6 +29,8 @@ type SetupPhase =
|
|
|
28
29
|
*/
|
|
29
30
|
export class NtfySetupOverlay implements Component {
|
|
30
31
|
private phase: SetupPhase = "instructions";
|
|
32
|
+
private scope: "global" | "project" = "global";
|
|
33
|
+
private scopeIndex = 0; // 0 = global, 1 = project
|
|
31
34
|
private serverUrl = "";
|
|
32
35
|
private topic = "";
|
|
33
36
|
private token = "";
|
|
@@ -41,12 +44,18 @@ export class NtfySetupOverlay implements Component {
|
|
|
41
44
|
private theme: Theme | null = null;
|
|
42
45
|
|
|
43
46
|
constructor() {
|
|
44
|
-
//
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Determine current scope and pre-fill from resolved config
|
|
48
|
+
const cwd = process.cwd();
|
|
49
|
+
const existingScope = getNtfyConfigScope(cwd);
|
|
50
|
+
if (existingScope !== "none") {
|
|
51
|
+
this.scope = existingScope;
|
|
52
|
+
this.scopeIndex = existingScope === "project" ? 1 : 0;
|
|
53
|
+
}
|
|
54
|
+
const config = loadNtfyConfig(cwd);
|
|
55
|
+
if (config.serverUrl) this.serverUrl = config.serverUrl;
|
|
56
|
+
if (config.topic) this.topic = config.topic;
|
|
57
|
+
if (config.token) this.token = config.token;
|
|
58
|
+
if (config.priority) this.priority = String(config.priority);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
setTheme(theme: Theme): void {
|
|
@@ -59,6 +68,21 @@ export class NtfySetupOverlay implements Component {
|
|
|
59
68
|
switch (this.phase) {
|
|
60
69
|
case "instructions":
|
|
61
70
|
if (data === "\r" || data === " ") {
|
|
71
|
+
this.phase = "scope";
|
|
72
|
+
} else if (data === "\x1b") {
|
|
73
|
+
this.onClose?.();
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case "scope":
|
|
78
|
+
if (data === "\x1b[A" || data === "k") {
|
|
79
|
+
// Up
|
|
80
|
+
this.scopeIndex = Math.max(0, this.scopeIndex - 1);
|
|
81
|
+
} else if (data === "\x1b[B" || data === "j") {
|
|
82
|
+
// Down
|
|
83
|
+
this.scopeIndex = Math.min(1, this.scopeIndex + 1);
|
|
84
|
+
} else if (data === "\r" || data === " ") {
|
|
85
|
+
this.scope = this.scopeIndex === 1 ? "project" : "global";
|
|
62
86
|
this.phase = this.serverUrl ? "topic" : "server-url";
|
|
63
87
|
} else if (data === "\x1b") {
|
|
64
88
|
this.onClose?.();
|
|
@@ -206,14 +230,13 @@ export class NtfySetupOverlay implements Component {
|
|
|
206
230
|
}
|
|
207
231
|
|
|
208
232
|
private saveConfig(): void {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
},
|
|
233
|
+
const cwd = process.cwd();
|
|
234
|
+
saveNtfyConfig(this.scope, cwd, {
|
|
235
|
+
enabled: true,
|
|
236
|
+
serverUrl: this.serverUrl.replace(/\/$/, ""),
|
|
237
|
+
topic: this.topic,
|
|
238
|
+
token: this.token || undefined,
|
|
239
|
+
priority: parseInt(this.priority, 10) || 3,
|
|
217
240
|
});
|
|
218
241
|
}
|
|
219
242
|
|
|
@@ -336,6 +359,36 @@ export class NtfySetupOverlay implements Component {
|
|
|
336
359
|
);
|
|
337
360
|
break;
|
|
338
361
|
|
|
362
|
+
case "scope": {
|
|
363
|
+
lines.push(
|
|
364
|
+
this.frameLine(
|
|
365
|
+
this.fg("dim", "Where should this config be saved?"),
|
|
366
|
+
innerWidth
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
lines.push(this.frameLine("", innerWidth));
|
|
370
|
+
const options = ["Global (all projects)", "Project (this project only)"];
|
|
371
|
+
for (let i = 0; i < options.length; i++) {
|
|
372
|
+
const isSelected = i === this.scopeIndex;
|
|
373
|
+
const label = isSelected ? this.bold(options[i]) : this.fg("dim", options[i]);
|
|
374
|
+
lines.push(
|
|
375
|
+
this.frameLine(
|
|
376
|
+
` ${isSelected ? this.fg("accent", "▸") : " "} ${label}`,
|
|
377
|
+
innerWidth
|
|
378
|
+
)
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
lines.push(this.frameLine("", innerWidth));
|
|
382
|
+
lines.push(this.ruleLine(innerWidth));
|
|
383
|
+
lines.push(
|
|
384
|
+
this.frameLine(
|
|
385
|
+
this.fg("dim", "↑↓ select · Enter confirm · Esc cancel"),
|
|
386
|
+
innerWidth
|
|
387
|
+
)
|
|
388
|
+
);
|
|
389
|
+
break;
|
|
390
|
+
}
|
|
391
|
+
|
|
339
392
|
case "server-url":
|
|
340
393
|
lines.push(
|
|
341
394
|
this.frameLine(
|
package/tui/settings-overlay.ts
CHANGED
|
@@ -13,7 +13,8 @@ import {
|
|
|
13
13
|
saveConfig,
|
|
14
14
|
validateConfig,
|
|
15
15
|
} from "../settings.js";
|
|
16
|
-
import
|
|
16
|
+
import { loadNtfyConfig, saveNtfyConfig, getNtfyConfigScope } from "../ntfy-config.js";
|
|
17
|
+
import type { NotifyConfig, NtfyConfig } from "../types.js";
|
|
17
18
|
|
|
18
19
|
/** Section types */
|
|
19
20
|
type Section = "platforms" | "events" | "recap";
|
|
@@ -23,6 +24,8 @@ type Section = "platforms" | "events" | "recap";
|
|
|
23
24
|
*/
|
|
24
25
|
export class NotifySettingsOverlay implements Component {
|
|
25
26
|
private config: NotifyConfig;
|
|
27
|
+
private ntfyConfig: NtfyConfig;
|
|
28
|
+
private ntfyScope: "project" | "global" | "none";
|
|
26
29
|
private section: Section = "platforms";
|
|
27
30
|
private selectedIndex = 0;
|
|
28
31
|
private error: string | null = null;
|
|
@@ -35,6 +38,9 @@ export class NotifySettingsOverlay implements Component {
|
|
|
35
38
|
|
|
36
39
|
constructor() {
|
|
37
40
|
this.config = loadConfig();
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
this.ntfyConfig = loadNtfyConfig(cwd);
|
|
43
|
+
this.ntfyScope = getNtfyConfigScope(cwd);
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
setTheme(theme: Theme): void {
|
|
@@ -93,7 +99,10 @@ export class NotifySettingsOverlay implements Component {
|
|
|
93
99
|
"ntfy",
|
|
94
100
|
];
|
|
95
101
|
const key = platforms[this.selectedIndex];
|
|
96
|
-
if (key) {
|
|
102
|
+
if (key === "ntfy") {
|
|
103
|
+
// ntfy toggle updates the resolved ntfy config
|
|
104
|
+
this.ntfyConfig.enabled = !this.ntfyConfig.enabled;
|
|
105
|
+
} else if (key) {
|
|
97
106
|
this.config[key].enabled = !this.config[key].enabled;
|
|
98
107
|
}
|
|
99
108
|
} else if (this.section === "recap") {
|
|
@@ -115,6 +124,10 @@ export class NotifySettingsOverlay implements Component {
|
|
|
115
124
|
}
|
|
116
125
|
this.error = null;
|
|
117
126
|
saveConfig(this.config);
|
|
127
|
+
// Save ntfy config to its own file if scope is known
|
|
128
|
+
if (this.ntfyScope !== "none") {
|
|
129
|
+
saveNtfyConfig(this.ntfyScope, process.cwd(), this.ntfyConfig);
|
|
130
|
+
}
|
|
118
131
|
this.saved = true;
|
|
119
132
|
setTimeout(() => this.onClose?.(), 500);
|
|
120
133
|
}
|
|
@@ -237,9 +250,9 @@ export class NotifySettingsOverlay implements Component {
|
|
|
237
250
|
{
|
|
238
251
|
key: "ntfy",
|
|
239
252
|
label: "ntfy",
|
|
240
|
-
detail: this.
|
|
241
|
-
? `
|
|
242
|
-
: "
|
|
253
|
+
detail: this.ntfyScope !== "none"
|
|
254
|
+
? `Topic: ${this.ntfyConfig.topic ?? "—"} · P${this.ntfyConfig.priority} · [${this.ntfyScope}]`
|
|
255
|
+
: "Not configured",
|
|
243
256
|
},
|
|
244
257
|
];
|
|
245
258
|
|
|
@@ -248,7 +261,9 @@ export class NotifySettingsOverlay implements Component {
|
|
|
248
261
|
const isSelected = i === this.selectedIndex;
|
|
249
262
|
const toggleOn = this.fg("success", "●");
|
|
250
263
|
const toggleOff = this.fg("dim", "○");
|
|
251
|
-
|
|
264
|
+
// ntfy enabled state comes from resolved ntfy.json, not config.json
|
|
265
|
+
const isEnabled = p.key === "ntfy" ? this.ntfyConfig.enabled : this.config[p.key].enabled;
|
|
266
|
+
const toggle = isEnabled ? toggleOn : toggleOff;
|
|
252
267
|
const label = isSelected ? this.bold(p.label) : this.fg("dim", p.label);
|
|
253
268
|
|
|
254
269
|
lines.push(
|