@pi-unipi/notify 0.1.7 → 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)) {
@@ -56,10 +58,15 @@ export function registerEventListeners(
56
58
  const eventConfig = config.events[eventKey];
57
59
  if (!eventConfig?.enabled) continue;
58
60
 
59
- const handler = async (payload: unknown) => {
61
+ const handler = (payload: unknown) => {
60
62
  const title = `Pi — ${def.label}`;
61
63
  const message = buildEventMessage(eventKey, payload);
62
- await dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config);
64
+ // Fire-and-forget: don't block the event emitter
65
+ dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
66
+ () => {
67
+ // Silently ignore — background notification failure is non-blocking.
68
+ }
69
+ );
63
70
  };
64
71
 
65
72
  (pi as any).on(def.hook, handler);
@@ -68,43 +75,48 @@ export function registerEventListeners(
68
75
  // agent_end — custom handler with session name and recap support
69
76
  const agentEndConfig = config.events["agent_end"];
70
77
  if (agentEndConfig?.enabled) {
71
- const handler = async (payload: unknown) => {
78
+ const handler = (payload: unknown) => {
79
+ // Fire-and-forget: build message and dispatch in background,
80
+ // don't block agent_end from completing
72
81
  const sessionName = pi.getSessionName?.();
73
82
  const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
74
- let message: string;
75
83
 
76
84
  if (config.recap.enabled) {
77
- // Recap mode: summarize last assistant message
85
+ // Recap mode: summarize asynchronously, then dispatch
78
86
  const lastText = extractLastAssistantText(payload);
79
87
  if (lastText && sessionCtx?.modelRegistry) {
80
88
  const provider = extractProvider(config.recap.model);
81
89
  const modelId = extractModelId(config.recap.model);
82
90
  const model = sessionCtx.modelRegistry.find(provider, modelId);
83
91
  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);
92
+ sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
93
+ .then((apiKeyResult) => {
94
+ const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
95
+ if (apiKey) {
96
+ return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
97
+ .then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
98
+ }
99
+ return buildAgentEndMessage(sessionName);
100
+ })
101
+ .catch(() => buildAgentEndMessage(sessionName))
102
+ .then((message) =>
103
+ dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd)
104
+ )
105
+ .catch(() => {
106
+ // Silently ignore — background agent_end notification failure is non-blocking.
107
+ });
108
+ return;
98
109
  }
99
- } else {
100
- message = buildAgentEndMessage(sessionName);
101
110
  }
102
- } else {
103
- // No recap: use session name based message
104
- message = buildAgentEndMessage(sessionName);
105
111
  }
106
112
 
107
- await dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config);
113
+ // No recap or recap unavailable: dispatch immediately in background
114
+ const message = buildAgentEndMessage(sessionName);
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
+ }
119
+ );
108
120
  };
109
121
 
110
122
  (pi as any).on("agent_end", handler);
@@ -122,12 +134,12 @@ export function registerEventListeners(
122
134
  }
123
135
 
124
136
  /** Get all platforms that are currently enabled in config */
125
- function getEnabledPlatforms(config: NotifyConfig): NotifyPlatform[] {
137
+ function getEnabledPlatforms(config: NotifyConfig, ntfyEnabled: boolean): NotifyPlatform[] {
126
138
  const enabled: NotifyPlatform[] = [];
127
139
  if (config.native.enabled) enabled.push("native");
128
140
  if (config.gotify.enabled) enabled.push("gotify");
129
141
  if (config.telegram.enabled) enabled.push("telegram");
130
- if (config.ntfy.enabled) enabled.push("ntfy");
142
+ if (ntfyEnabled) enabled.push("ntfy");
131
143
  return enabled;
132
144
  }
133
145
 
@@ -144,34 +156,35 @@ export async function dispatchNotification(
144
156
  message: string,
145
157
  eventPlatforms: NotifyPlatform[],
146
158
  eventType: string,
147
- config: NotifyConfig
159
+ config: NotifyConfig,
160
+ cwd: string
148
161
  ): Promise<NotifyDispatchResult> {
162
+ // Resolve ntfy config from project/global ntfy.json
163
+ const ntfyConfig = loadNtfyConfig(cwd);
164
+
149
165
  // Resolve platforms: event-specific → all enabled → global defaults
150
166
  const platforms =
151
167
  eventPlatforms.length > 0
152
168
  ? eventPlatforms
153
- : getEnabledPlatforms(config).length > 0
154
- ? getEnabledPlatforms(config)
169
+ : getEnabledPlatforms(config, ntfyConfig.enabled).length > 0
170
+ ? getEnabledPlatforms(config, ntfyConfig.enabled)
155
171
  : config.defaultPlatforms;
156
172
 
157
173
  const enabledPlatforms = platforms.filter((p) => {
158
174
  if (p === "native") return config.native.enabled;
159
175
  if (p === "gotify") return config.gotify.enabled;
160
176
  if (p === "telegram") return config.telegram.enabled;
161
- if (p === "ntfy") return config.ntfy.enabled;
177
+ if (p === "ntfy") return ntfyConfig.enabled;
162
178
  return false;
163
179
  });
164
180
 
165
181
  const results = await Promise.all(
166
182
  enabledPlatforms.map(async (platform) => {
167
183
  try {
168
- await sendToPlatform(platform, title, message, config);
184
+ await sendToPlatform(platform, title, message, config, cwd);
169
185
  return { platform, success: true };
170
186
  } catch (err) {
171
- console.error(
172
- `[notify] Failed to send via ${platform}:`,
173
- err instanceof Error ? err.message : err
174
- );
187
+ // Silently ignore — platform send failure is tracked in results.
175
188
  return {
176
189
  platform,
177
190
  success: false,
@@ -199,7 +212,8 @@ async function sendToPlatform(
199
212
  platform: NotifyPlatform,
200
213
  title: string,
201
214
  message: string,
202
- config: NotifyConfig
215
+ config: NotifyConfig,
216
+ cwd: string
203
217
  ): Promise<void> {
204
218
  switch (platform) {
205
219
  case "native":
@@ -230,19 +244,22 @@ async function sendToPlatform(
230
244
  message
231
245
  );
232
246
  break;
233
- case "ntfy":
234
- 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) {
235
251
  throw new Error("ntfy: serverUrl and topic are required");
236
252
  }
237
253
  await sendNtfyNotification(
238
- config.ntfy.serverUrl,
239
- config.ntfy.topic,
254
+ ntfyConfig.serverUrl,
255
+ ntfyConfig.topic,
240
256
  title,
241
257
  message,
242
- config.ntfy.priority,
243
- config.ntfy.token
258
+ ntfyConfig.priority,
259
+ ntfyConfig.token
244
260
  );
245
261
  break;
262
+ }
246
263
  }
247
264
  }
248
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.7",
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
@@ -9,7 +9,6 @@ import { Type } from "@sinclair/typebox";
9
9
  import { NOTIFY_TOOLS } from "@pi-unipi/core";
10
10
  import { loadConfig } from "./settings.js";
11
11
  import { dispatchNotification } from "./events.js";
12
- import type { NotifyDispatchResult } from "./types.js";
13
12
 
14
13
  /** Schema for notify_user tool parameters */
15
14
  const NotifyUserSchema = Type.Object({
@@ -43,7 +42,7 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
43
42
  "Send a notification to the user's configured platforms (native OS, Gotify, Telegram, ntfy). " +
44
43
  "Use for critical errors, completion of long-running tasks, or when the user explicitly asked to be notified.",
45
44
  parameters: NotifyUserSchema,
46
- async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
45
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx: ExtensionContext) {
47
46
  const {
48
47
  message,
49
48
  title,
@@ -64,32 +63,28 @@ export function registerNotifyTools(pi: ExtensionAPI): void {
64
63
  // Resolve platforms — use params.platforms or global defaults
65
64
  const notifPlatforms = platforms || config.defaultPlatforms;
66
65
 
67
- // Dispatch notification
68
- const result: NotifyDispatchResult = await dispatchNotification(
66
+ // Fire-and-forget: dispatch in background so the tool doesn't block the agent
67
+ const cwd = process.cwd();
68
+ dispatchNotification(
69
69
  pi,
70
70
  notifTitle,
71
71
  message,
72
72
  notifPlatforms,
73
73
  "agent_tool",
74
- config
75
- );
76
-
77
- // Format result
78
- const platformResults = result.results.map(
79
- (r) => `${r.platform}: ${r.success ? "✓ sent" : `✗ ${r.error || "failed"}`}`
80
- );
74
+ config,
75
+ cwd
76
+ ).catch(() => {
77
+ // Silently ignore — background dispatch failure is non-blocking.
78
+ });
81
79
 
82
80
  return {
83
81
  content: [
84
82
  {
85
83
  type: "text" as const,
86
- text: `Notification sent to ${result.results.length} platform(s):\n${platformResults.join("\n")}`,
84
+ text: `Notification sending to ${notifPlatforms.length} platform(s): ${notifPlatforms.join(", ")}`,
87
85
  },
88
86
  ],
89
- details: {
90
- platforms: result.results.map((r) => r.platform),
91
- allSuccess: result.allSuccess,
92
- },
87
+ details: { platforms: notifPlatforms },
93
88
  };
94
89
  },
95
90
  });
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(