@pi-unipi/notify 0.1.8 → 0.1.9

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/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
- if (config.ntfy.enabled && config.ntfy.serverUrl && config.ntfy.topic) {
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
- config.ntfy.serverUrl,
318
- config.ntfy.topic,
319
+ ntfyConfig.serverUrl,
320
+ ntfyConfig.topic,
319
321
  title,
320
322
  message,
321
- config.ntfy.priority,
322
- config.ntfy.token
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
- (err) => console.error(`[notify] Background notification failed for ${eventKey}:`, err)
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((err) => console.error("[notify] Background agent_end notification failed:", err));
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
- (err) => console.error("[notify] Background agent_end notification failed:", err)
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 (config.ntfy.enabled) enabled.push("ntfy");
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 config.ntfy.enabled;
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
- console.error(
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
- if (!config.ntfy.serverUrl || !config.ntfy.topic) {
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
- config.ntfy.serverUrl,
243
- config.ntfy.topic,
254
+ ntfyConfig.serverUrl,
255
+ ntfyConfig.topic,
244
256
  title,
245
257
  message,
246
- config.ntfy.priority,
247
- config.ntfy.token
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
@@ -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 location
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 `config.json` directly with the fields above
97
- 3. **Agent can write config:** Read the current config, merge changes, write back
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
- ).catch((err) =>
75
- console.error("[notify] Background notify_user dispatch failed:", err)
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 { updateConfig, loadConfig } from "../settings.js";
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
- // Pre-fill from existing config if available
45
- const config = loadConfig();
46
- if (config.ntfy.serverUrl) this.serverUrl = config.ntfy.serverUrl;
47
- if (config.ntfy.topic) this.topic = config.ntfy.topic;
48
- if (config.ntfy.token) this.token = config.ntfy.token;
49
- if (config.ntfy.priority) this.priority = String(config.ntfy.priority);
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
- updateConfig({
210
- ntfy: {
211
- enabled: true,
212
- serverUrl: this.serverUrl.replace(/\/$/, ""),
213
- topic: this.topic,
214
- token: this.token || undefined,
215
- priority: parseInt(this.priority, 10) || 3,
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(
@@ -13,7 +13,8 @@ import {
13
13
  saveConfig,
14
14
  validateConfig,
15
15
  } from "../settings.js";
16
- import type { NotifyConfig } from "../types.js";
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.config.ntfy.serverUrl
241
- ? `Server: ${this.config.ntfy.serverUrl}`
242
- : "Self-hosted push service",
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
- const toggle = this.config[p.key].enabled ? toggleOn : toggleOff;
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(