@pi-unipi/notify 0.1.4 → 0.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pi-unipi/notify
2
2
 
3
- Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, and Telegram when agent lifecycle events occur.
3
+ Cross-platform notification extension for Pi. Sends push notifications to native OS, Gotify, Telegram, and ntfy when agent lifecycle events occur.
4
4
 
5
5
  ## What it does
6
6
 
@@ -52,12 +52,35 @@ This guides you through:
52
52
  2. Pasting the bot token
53
53
  3. Auto-detecting your chat ID
54
54
 
55
+ ### ntfy
56
+
57
+ HTTP-based pub-sub notifications via [ntfy.sh](https://ntfy.sh) or self-hosted. Run setup command:
58
+
59
+ ```
60
+ /unipi:notify-set-ntfy
61
+ ```
62
+
63
+ Or configure manually:
64
+
65
+ ```json
66
+ {
67
+ "ntfy": {
68
+ "enabled": true,
69
+ "serverUrl": "https://ntfy.sh",
70
+ "topic": "your-topic-name",
71
+ "priority": 3
72
+ }
73
+ }
74
+ ```
75
+
55
76
  ## Commands
56
77
 
57
78
  | Command | Description |
58
79
  |---------|-------------|
59
80
  | `/unipi:notify-settings` | Open settings overlay to configure platforms and events |
81
+ | `/unipi:notify-set-gotify` | Configure Gotify server connection |
60
82
  | `/unipi:notify-set-tg` | Interactive Telegram bot setup |
83
+ | `/unipi:notify-set-ntfy` | Configure ntfy topic and server |
61
84
  | `/unipi:notify-test` | Send test notification to all enabled platforms |
62
85
 
63
86
  ## Agent Tool
package/commands.ts CHANGED
@@ -10,10 +10,13 @@ import { NOTIFY_COMMANDS } from "@pi-unipi/core";
10
10
  import { NotifySettingsOverlay } from "./tui/settings-overlay.js";
11
11
  import { GotifySetupOverlay } from "./tui/gotify-setup.js";
12
12
  import { TelegramSetupOverlay } from "./tui/telegram-setup.js";
13
+ import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
14
+ import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
13
15
  import { loadConfig } from "./settings.js";
14
16
  import { sendNativeNotification } from "./platforms/native.js";
15
17
  import { sendGotifyNotification } from "./platforms/gotify.js";
16
18
  import { sendTelegramNotification } from "./platforms/telegram.js";
19
+ import { sendNtfyNotification } from "./platforms/ntfy.js";
17
20
 
18
21
  /**
19
22
  * Register notify commands.
@@ -36,6 +39,34 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
36
39
  overlay.setTheme(theme);
37
40
  overlay.onClose = () => done(undefined);
38
41
  overlay.requestRender = () => tui.requestRender();
42
+ overlay.onOpenModelSelector = () => {
43
+ // Open model selector as nested overlay
44
+ ctx.ui.custom(
45
+ (innerTui: any, innerTheme: any, _innerKb: any, innerDone: any) => {
46
+ const selector = new RecapModelSelectorOverlay();
47
+ selector.setTheme(innerTheme);
48
+ selector.onClose = () => innerDone(undefined);
49
+ selector.requestRender = () => innerTui.requestRender();
50
+ return {
51
+ render: (w: number) => selector.render(w),
52
+ invalidate: () => selector.invalidate(),
53
+ handleInput: (data: string) => {
54
+ selector.handleInput(data);
55
+ innerTui.requestRender();
56
+ },
57
+ };
58
+ },
59
+ {
60
+ overlay: true,
61
+ overlayOptions: {
62
+ width: "60%",
63
+ minWidth: 40,
64
+ anchor: "center",
65
+ margin: 4,
66
+ },
67
+ }
68
+ );
69
+ };
39
70
  return {
40
71
  render: (w: number) => overlay.render(w),
41
72
  invalidate: () => overlay.invalidate(),
@@ -59,6 +90,46 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
59
90
  }
60
91
  );
61
92
 
93
+ // /unipi:notify-recap-model — Open recap model selector directly
94
+ pi.registerCommand(
95
+ `${UNIPI_PREFIX}${NOTIFY_COMMANDS.RECAP_MODEL}`,
96
+ {
97
+ description: "Select model for notification recaps",
98
+ handler: async (_args: string, ctx: ExtensionContext) => {
99
+ if (!ctx.hasUI) {
100
+ ctx.ui.notify("Model selector requires an interactive UI.", "warning");
101
+ return;
102
+ }
103
+
104
+ ctx.ui.custom(
105
+ (tui: any, theme: any, _keybindings: any, done: any) => {
106
+ const overlay = new RecapModelSelectorOverlay();
107
+ overlay.setTheme(theme);
108
+ overlay.onClose = () => done(undefined);
109
+ overlay.requestRender = () => tui.requestRender();
110
+ return {
111
+ render: (w: number) => overlay.render(w),
112
+ invalidate: () => overlay.invalidate(),
113
+ handleInput: (data: string) => {
114
+ overlay.handleInput(data);
115
+ tui.requestRender();
116
+ },
117
+ };
118
+ },
119
+ {
120
+ overlay: true,
121
+ overlayOptions: {
122
+ width: "60%",
123
+ minWidth: 40,
124
+ anchor: "center",
125
+ margin: 4,
126
+ },
127
+ }
128
+ );
129
+ },
130
+ }
131
+ );
132
+
62
133
  // /unipi:notify-set-gotify — Interactive Gotify setup
63
134
  pi.registerCommand(
64
135
  `${UNIPI_PREFIX}${NOTIFY_COMMANDS.SET_GOTIFY}`,
@@ -139,6 +210,46 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
139
210
  }
140
211
  );
141
212
 
213
+ // /unipi:notify-set-ntfy — Interactive ntfy setup
214
+ pi.registerCommand(
215
+ `${UNIPI_PREFIX}${NOTIFY_COMMANDS.SET_NTFY}`,
216
+ {
217
+ description: "Set up ntfy push notifications with connection test",
218
+ handler: async (_args: string, ctx: ExtensionContext) => {
219
+ if (!ctx.hasUI) {
220
+ ctx.ui.notify("ntfy setup requires an interactive UI.", "warning");
221
+ return;
222
+ }
223
+
224
+ ctx.ui.custom(
225
+ (tui: any, theme: any, _keybindings: any, done: any) => {
226
+ const overlay = new NtfySetupOverlay();
227
+ overlay.setTheme(theme);
228
+ overlay.onClose = () => done(undefined);
229
+ overlay.requestRender = () => tui.requestRender();
230
+ return {
231
+ render: (w: number) => overlay.render(w),
232
+ invalidate: () => overlay.invalidate(),
233
+ handleInput: (data: string) => {
234
+ overlay.handleInput(data);
235
+ tui.requestRender();
236
+ },
237
+ };
238
+ },
239
+ {
240
+ overlay: true,
241
+ overlayOptions: {
242
+ width: "80%",
243
+ minWidth: 60,
244
+ anchor: "center",
245
+ margin: 2,
246
+ },
247
+ }
248
+ );
249
+ },
250
+ }
251
+ );
252
+
142
253
  // /unipi:notify-test — Send test notification to all enabled platforms
143
254
  pi.registerCommand(
144
255
  `${UNIPI_PREFIX}${NOTIFY_COMMANDS.TEST}`,
@@ -199,6 +310,25 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
199
310
  }
200
311
  }
201
312
 
313
+ // ntfy
314
+ if (config.ntfy.enabled && config.ntfy.serverUrl && config.ntfy.topic) {
315
+ try {
316
+ await sendNtfyNotification(
317
+ config.ntfy.serverUrl,
318
+ config.ntfy.topic,
319
+ title,
320
+ message,
321
+ config.ntfy.priority,
322
+ config.ntfy.token
323
+ );
324
+ results.push("✓ ntfy: sent");
325
+ } catch (err) {
326
+ results.push(
327
+ `✗ ntfy: ${err instanceof Error ? err.message : "failed"}`
328
+ );
329
+ }
330
+ }
331
+
202
332
  if (results.length === 0) {
203
333
  ctx.ui.notify("No platforms enabled. Use /unipi:notify-settings first.", "warning");
204
334
  } else {
package/events.ts CHANGED
@@ -5,12 +5,27 @@
5
5
  * Supports built-in events and dynamic discovery via MODULE_READY.
6
6
  */
7
7
 
8
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
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
11
  import { sendNativeNotification } from "./platforms/native.js";
12
12
  import { sendGotifyNotification } from "./platforms/gotify.js";
13
13
  import { sendTelegramNotification } from "./platforms/telegram.js";
14
+ import { sendNtfyNotification } from "./platforms/ntfy.js";
15
+ import { summarizeLastMessage } from "./summarize.js";
16
+
17
+ /** Stored session context for modelRegistry access */
18
+ let sessionCtx: ExtensionContext | null = null;
19
+
20
+ /** Store session context (called from index.ts on session_start) */
21
+ export function setSessionContext(ctx: ExtensionContext): void {
22
+ sessionCtx = ctx;
23
+ }
24
+
25
+ /** Clear session context (called on session_shutdown) */
26
+ export function clearSessionContext(): void {
27
+ sessionCtx = null;
28
+ }
14
29
 
15
30
  /** Built-in event definitions — maps event key to pi hook + display label */
16
31
  export const BUILTIN_EVENTS: Record<
@@ -23,6 +38,7 @@ export const BUILTIN_EVENTS: Record<
23
38
  mcp_server_error: { hook: UNIPI_EVENTS.MCP_SERVER_ERROR, label: "MCP Error" },
24
39
  memory_consolidated: { hook: UNIPI_EVENTS.MEMORY_CONSOLIDATED, label: "Memory Saved" },
25
40
  session_shutdown: { hook: "session_shutdown", label: "Session End" },
41
+ ask_user_prompt: { hook: UNIPI_EVENTS.ASK_USER_PROMPT, label: "Question Asked" },
26
42
  };
27
43
 
28
44
  /**
@@ -33,8 +49,10 @@ export function registerEventListeners(
33
49
  pi: ExtensionAPI,
34
50
  config: NotifyConfig
35
51
  ): void {
36
- // Register built-in events
52
+ // Register built-in events (except agent_end which has custom logic)
37
53
  for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
54
+ if (eventKey === "agent_end") continue; // handled separately below
55
+
38
56
  const eventConfig = config.events[eventKey];
39
57
  if (!eventConfig?.enabled) continue;
40
58
 
@@ -47,6 +65,51 @@ export function registerEventListeners(
47
65
  (pi as any).on(def.hook, handler);
48
66
  }
49
67
 
68
+ // agent_end — custom handler with session name and recap support
69
+ const agentEndConfig = config.events["agent_end"];
70
+ if (agentEndConfig?.enabled) {
71
+ const handler = async (payload: unknown) => {
72
+ const sessionName = pi.getSessionName?.();
73
+ const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
74
+ let message: string;
75
+
76
+ if (config.recap.enabled) {
77
+ // Recap mode: summarize last assistant message
78
+ const lastText = extractLastAssistantText(payload);
79
+ if (lastText && sessionCtx?.modelRegistry) {
80
+ const provider = extractProvider(config.recap.model);
81
+ const modelId = extractModelId(config.recap.model);
82
+ const model = sessionCtx.modelRegistry.find(provider, modelId);
83
+ if (model) {
84
+ try {
85
+ const apiKeyResult = await sessionCtx.modelRegistry.getApiKeyAndHeaders(model);
86
+ const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
87
+ if (apiKey) {
88
+ const recap = await summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId);
89
+ message = sessionName ? `${sessionName}: ${recap}` : recap;
90
+ } else {
91
+ message = buildAgentEndMessage(sessionName);
92
+ }
93
+ } catch {
94
+ message = buildAgentEndMessage(sessionName);
95
+ }
96
+ } else {
97
+ message = buildAgentEndMessage(sessionName);
98
+ }
99
+ } else {
100
+ message = buildAgentEndMessage(sessionName);
101
+ }
102
+ } else {
103
+ // No recap: use session name based message
104
+ message = buildAgentEndMessage(sessionName);
105
+ }
106
+
107
+ await dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config);
108
+ };
109
+
110
+ (pi as any).on("agent_end", handler);
111
+ }
112
+
50
113
  // Listen for dynamic module events
51
114
  const moduleHandler = async (payload: unknown) => {
52
115
  const modPayload = payload as { name?: string; tools?: string[] };
@@ -58,6 +121,16 @@ export function registerEventListeners(
58
121
  (pi as any).on(UNIPI_EVENTS.MODULE_READY, moduleHandler);
59
122
  }
60
123
 
124
+ /** Get all platforms that are currently enabled in config */
125
+ function getEnabledPlatforms(config: NotifyConfig): NotifyPlatform[] {
126
+ const enabled: NotifyPlatform[] = [];
127
+ if (config.native.enabled) enabled.push("native");
128
+ if (config.gotify.enabled) enabled.push("gotify");
129
+ if (config.telegram.enabled) enabled.push("telegram");
130
+ if (config.ntfy.enabled) enabled.push("ntfy");
131
+ return enabled;
132
+ }
133
+
61
134
  /** No-op — cleanup handled by session teardown */
62
135
  export function unregisterEventListeners(): void {}
63
136
 
@@ -73,13 +146,19 @@ export async function dispatchNotification(
73
146
  eventType: string,
74
147
  config: NotifyConfig
75
148
  ): Promise<NotifyDispatchResult> {
149
+ // Resolve platforms: event-specific → all enabled → global defaults
76
150
  const platforms =
77
- eventPlatforms.length > 0 ? eventPlatforms : config.defaultPlatforms;
151
+ eventPlatforms.length > 0
152
+ ? eventPlatforms
153
+ : getEnabledPlatforms(config).length > 0
154
+ ? getEnabledPlatforms(config)
155
+ : config.defaultPlatforms;
78
156
 
79
157
  const enabledPlatforms = platforms.filter((p) => {
80
158
  if (p === "native") return config.native.enabled;
81
159
  if (p === "gotify") return config.gotify.enabled;
82
160
  if (p === "telegram") return config.telegram.enabled;
161
+ if (p === "ntfy") return config.ntfy.enabled;
83
162
  return false;
84
163
  });
85
164
 
@@ -151,6 +230,19 @@ async function sendToPlatform(
151
230
  message
152
231
  );
153
232
  break;
233
+ case "ntfy":
234
+ if (!config.ntfy.serverUrl || !config.ntfy.topic) {
235
+ throw new Error("ntfy: serverUrl and topic are required");
236
+ }
237
+ await sendNtfyNotification(
238
+ config.ntfy.serverUrl,
239
+ config.ntfy.topic,
240
+ title,
241
+ message,
242
+ config.ntfy.priority,
243
+ config.ntfy.token
244
+ );
245
+ break;
154
246
  }
155
247
  }
156
248
 
@@ -171,7 +263,67 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
171
263
  return `Memory consolidated (${p.count || 0} items)`;
172
264
  case "session_shutdown":
173
265
  return "Session ending";
266
+ case "ask_user_prompt":
267
+ return p.context
268
+ ? `Agent asks: ${String(p.question || "")} — ${String(p.context)}`
269
+ : `Agent asks: ${String(p.question || "A question")}`;
174
270
  default:
175
271
  return p.message ? String(p.message) : "Event occurred";
176
272
  }
177
273
  }
274
+
275
+ /** Build agent_end message using session name */
276
+ function buildAgentEndMessage(sessionName: string | undefined): string {
277
+ if (sessionName) return `${sessionName} - Agent is complete`;
278
+ return "Agent is complete";
279
+ }
280
+
281
+ /** Extract text from the last assistant message in agent_end payload */
282
+ function extractLastAssistantText(payload: unknown): string | null {
283
+ const p = payload as { messages?: Array<{ role?: string; content?: unknown }> };
284
+ if (!p?.messages || !Array.isArray(p.messages)) return null;
285
+
286
+ // Find last assistant message
287
+ for (let i = p.messages.length - 1; i >= 0; i--) {
288
+ const msg = p.messages[i];
289
+ if (msg?.role !== "assistant") continue;
290
+
291
+ const content = msg.content;
292
+ if (typeof content === "string") return content;
293
+ if (Array.isArray(content)) {
294
+ // Extract text blocks from content array
295
+ const textParts: string[] = [];
296
+ for (const block of content) {
297
+ if (typeof block === "object" && block !== null) {
298
+ const b = block as { type?: string; text?: string };
299
+ if (b.type === "text" && typeof b.text === "string") {
300
+ textParts.push(b.text);
301
+ }
302
+ }
303
+ }
304
+ if (textParts.length > 0) return textParts.join("\n");
305
+ }
306
+ }
307
+
308
+ return null;
309
+ }
310
+
311
+ /** Extract provider from model reference (e.g. "openrouter/openai/gpt-oss-20b" → "openrouter") */
312
+ function extractProvider(modelRef: string): string {
313
+ const slashIdx = modelRef.indexOf("/");
314
+ return slashIdx > 0 ? modelRef.slice(0, slashIdx) : modelRef;
315
+ }
316
+
317
+ /** Extract model ID from full reference (e.g. "openrouter/openai/gpt-oss-20b" → "openai/gpt-oss-20b") */
318
+ function extractModelId(modelRef: string): string {
319
+ const slashIdx = modelRef.indexOf("/");
320
+ return slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
321
+ }
322
+
323
+ /** Resolve API key for a provider from environment variables */
324
+ function resolveApiKey(modelRef: string): string | undefined {
325
+ const provider = extractProvider(modelRef);
326
+ // Try standard env var patterns
327
+ const envKey = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
328
+ return process.env[envKey];
329
+ }
package/index.ts CHANGED
@@ -19,6 +19,8 @@ import { loadConfig } from "./settings.js";
19
19
  import {
20
20
  registerEventListeners,
21
21
  unregisterEventListeners,
22
+ setSessionContext,
23
+ clearSessionContext,
22
24
  } from "./events.js";
23
25
 
24
26
  /** Package version */
@@ -38,20 +40,22 @@ export default function (pi: ExtensionAPI) {
38
40
  registerNotifyCommands(pi);
39
41
 
40
42
  // Session lifecycle — register events and announce module
41
- pi.on("session_start", async () => {
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ setSessionContext(ctx);
42
45
  const config = loadConfig();
43
46
  registerEventListeners(pi, config);
44
47
 
45
48
  emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
46
49
  name: MODULES.NOTIFY,
47
50
  version: VERSION,
48
- commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-test"],
51
+ commands: ["unipi:notify-settings", "unipi:notify-set-gotify", "unipi:notify-set-tg", "unipi:notify-set-ntfy", "unipi:notify-test", "unipi:notify-recap-model"],
49
52
  tools: [NOTIFY_TOOLS.NOTIFY_USER],
50
53
  });
51
54
  });
52
55
 
53
56
  // Cleanup on session shutdown
54
57
  pi.on("session_shutdown", async () => {
58
+ clearSessionContext();
55
59
  unregisterEventListeners();
56
60
  });
57
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @pi-unipi/notify — ntfy notification platform
3
+ *
4
+ * Sends push notifications to an ntfy server via HTTP POST.
5
+ * ntfy is a simple HTTP-based pub-sub notification service.
6
+ * Supports self-hosted instances and ntfy.sh (public).
7
+ */
8
+
9
+ /** Send a notification to an ntfy server */
10
+ export async function sendNtfyNotification(
11
+ serverUrl: string,
12
+ topic: string,
13
+ title: string,
14
+ message: string,
15
+ priority: number = 3,
16
+ token?: string
17
+ ): Promise<void> {
18
+ // ntfy supports POSTing to the server root with a JSON body that carries
19
+ // topic/title/message/priority. JSON bodies are UTF-8 safe, unlike HTTP
20
+ // headers which must be ByteString (Latin-1) and reject characters like
21
+ // em dash (U+2014). See https://docs.ntfy.sh/publish/#publish-as-json
22
+ const url = serverUrl.replace(/\/$/, "");
23
+ const headers: Record<string, string> = {
24
+ "Content-Type": "application/json",
25
+ };
26
+
27
+ if (token) {
28
+ headers["Authorization"] = `Bearer ${token}`;
29
+ }
30
+
31
+ const body = JSON.stringify({
32
+ topic,
33
+ title,
34
+ message,
35
+ priority: Math.max(1, Math.min(5, priority)),
36
+ });
37
+
38
+ const response = await fetch(url, {
39
+ method: "POST",
40
+ headers,
41
+ body,
42
+ });
43
+
44
+ if (!response.ok) {
45
+ const resBody = await response.text().catch(() => "<no body>");
46
+ throw new Error(`ntfy API error ${response.status}: ${resBody}`);
47
+ }
48
+ }
package/settings.ts CHANGED
@@ -26,6 +26,7 @@ export const DEFAULT_CONFIG: NotifyConfig = {
26
26
  agent_end: { enabled: false, platforms: [] },
27
27
  memory_consolidated: { enabled: false, platforms: [] },
28
28
  session_shutdown: { enabled: false, platforms: [] },
29
+ ask_user_prompt: { enabled: false, platforms: [] },
29
30
  },
30
31
  native: {
31
32
  enabled: true,
@@ -37,6 +38,15 @@ export const DEFAULT_CONFIG: NotifyConfig = {
37
38
  telegram: {
38
39
  enabled: false,
39
40
  },
41
+ ntfy: {
42
+ enabled: false,
43
+ serverUrl: "https://ntfy.sh",
44
+ priority: 3,
45
+ },
46
+ recap: {
47
+ enabled: false,
48
+ model: "openrouter/openai/gpt-oss-20b",
49
+ },
40
50
  };
41
51
 
42
52
  /** Load config from disk, returning defaults if missing or invalid */
@@ -49,11 +59,8 @@ export function loadConfig(): NotifyConfig {
49
59
  // Merge with defaults to ensure new fields are present
50
60
  return mergeWithDefaults(parsed);
51
61
  }
52
- } catch (err) {
53
- console.warn(
54
- `[notify] Failed to load config from ${configPath}, using defaults:`,
55
- err
56
- );
62
+ } catch (_err) {
63
+ // Config load failure — using defaults silently.
57
64
  }
58
65
  return { ...DEFAULT_CONFIG };
59
66
  }
@@ -100,6 +107,19 @@ export function validateConfig(config: NotifyConfig): string[] {
100
107
  errors.push("Gotify: priority must be between 1 and 10");
101
108
  }
102
109
 
110
+ if (config.ntfy.enabled) {
111
+ if (!config.ntfy.serverUrl) {
112
+ errors.push("ntfy: serverUrl is required");
113
+ }
114
+ if (!config.ntfy.topic) {
115
+ errors.push("ntfy: topic is required");
116
+ }
117
+ }
118
+
119
+ if (config.ntfy.priority < 1 || config.ntfy.priority > 5) {
120
+ errors.push("ntfy: priority must be between 1 and 5");
121
+ }
122
+
103
123
  return errors;
104
124
  }
105
125
 
@@ -111,5 +131,7 @@ function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
111
131
  native: { ...DEFAULT_CONFIG.native, ...loaded.native },
112
132
  gotify: { ...DEFAULT_CONFIG.gotify, ...loaded.gotify },
113
133
  telegram: { ...DEFAULT_CONFIG.telegram, ...loaded.telegram },
134
+ ntfy: { ...DEFAULT_CONFIG.ntfy, ...loaded.ntfy },
135
+ recap: { ...DEFAULT_CONFIG.recap, ...loaded.recap },
114
136
  };
115
137
  }
@@ -47,6 +47,13 @@ Help users configure the `@pi-unipi/notify` notification system.
47
47
  "enabled": false,
48
48
  "botToken": null,
49
49
  "chatId": null
50
+ },
51
+ "ntfy": {
52
+ "enabled": false,
53
+ "serverUrl": "https://ntfy.sh",
54
+ "topic": null,
55
+ "token": null,
56
+ "priority": 3
50
57
  }
51
58
  }
52
59
  ```
@@ -75,6 +82,20 @@ Bot API notifications. Requires:
75
82
  - `botToken` — From @BotFather
76
83
  - `chatId` — Auto-detected by `/unipi:notify-set-tg`
77
84
 
85
+ ### ntfy (default: disabled)
86
+
87
+ Simple HTTP-based pub-sub notification service. Supports public [ntfy.sh](https://ntfy.sh) and self-hosted instances.
88
+ Requires:
89
+ - `serverUrl` — ntfy server URL (default: `https://ntfy.sh`)
90
+ - `topic` — Topic name to publish to (acts as a channel)
91
+ - `token` — Optional access token for authenticated servers
92
+ - `priority` — 1-5 (default: 3)
93
+
94
+ **Setup options:**
95
+ 1. **Interactive overlay:** Run `/unipi:notify-set-ntfy` for guided setup with connection test
96
+ 2. **Manual config:** Edit `config.json` directly with the fields above
97
+ 3. **Agent can write config:** Read the current config, merge changes, write back
98
+
78
99
  ## Commands
79
100
 
80
101
  | Command | Description |
@@ -82,6 +103,7 @@ Bot API notifications. Requires:
82
103
  | `/unipi:notify-settings` | TUI overlay to toggle platforms and events |
83
104
  | `/unipi:notify-set-gotify` | Interactive Gotify setup wizard |
84
105
  | `/unipi:notify-set-tg` | Interactive Telegram setup wizard |
106
+ | `/unipi:notify-set-ntfy` | Interactive ntfy setup wizard |
85
107
  | `/unipi:notify-test` | Send test notification to all enabled platforms |
86
108
 
87
109
  ## Events
@@ -125,6 +147,7 @@ Read the JSON, make changes, write it back. Example:
125
147
 
126
148
  For Gotify: suggest running `/unipi:notify-set-gotify`
127
149
  For Telegram: suggest running `/unipi:notify-set-tg`
150
+ For ntfy: suggest running `/unipi:notify-set-ntfy`
128
151
  For general settings: suggest `/unipi:notify-settings`
129
152
 
130
153
  ## Validation rules
@@ -132,3 +155,5 @@ For general settings: suggest `/unipi:notify-settings`
132
155
  - Gotify: `serverUrl` and `appToken` required when enabled
133
156
  - Gotify: `priority` must be 1-10
134
157
  - Telegram: `botToken` and `chatId` required when enabled
158
+ - ntfy: `serverUrl` and `topic` required when enabled
159
+ - ntfy: `priority` must be 1-5
package/tools.ts CHANGED
@@ -26,7 +26,7 @@ const NotifyUserSchema = Type.Object({
26
26
  ),
27
27
  platforms: Type.Optional(
28
28
  Type.Array(
29
- Type.String({ enum: ["native", "gotify", "telegram"] }),
29
+ Type.String({ enum: ["native", "gotify", "telegram", "ntfy"] }),
30
30
  { description: "Override platforms for this notification" }
31
31
  )
32
32
  ),
@@ -40,7 +40,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
40
40
  name: NOTIFY_TOOLS.NOTIFY_USER,
41
41
  label: "Notify User",
42
42
  description:
43
- "Send a notification to the user's configured platforms (native OS, Gotify, Telegram). " +
43
+ "Send a notification to the user's configured platforms (native OS, Gotify, Telegram, ntfy). " +
44
44
  "Use for critical errors, completion of long-running tasks, or when the user explicitly asked to be notified.",
45
45
  parameters: NotifyUserSchema,
46
46
  async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
@@ -53,7 +53,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
53
53
  message: string;
54
54
  title?: string;
55
55
  priority?: "low" | "normal" | "high";
56
- platforms?: Array<"native" | "gotify" | "telegram">;
56
+ platforms?: Array<"native" | "gotify" | "telegram" | "ntfy">;
57
57
  };
58
58
 
59
59
  const config = loadConfig();