@pi-unipi/notify 2.18.0 → 2.19.1

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
@@ -32,6 +32,8 @@ Notify subscribes to Pi lifecycle events and routes notifications based on your
32
32
  | `ask_user_prompt` | Off | Agent asked a question and is waiting for an answer |
33
33
  | `permission_request` | Off | A permission prompt is about to be shown (requires [`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system)) |
34
34
 
35
+ `ask_user_prompt` and `permission_request` are **blocking** events: while one is unanswered the agent is parked, so notify re-sends it periodically (see [Re-notify unanswered prompts](#re-notify-unanswered-prompts)).
36
+
35
37
  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.
36
38
 
37
39
  ## Agent Tool
@@ -75,7 +77,25 @@ After a terminal keypress, listed platforms stay quiet for `windowMs`. Default:
75
77
  }
76
78
  ```
77
79
 
78
- Add `gotify`, `telegram`, or `ntfy` to `platforms` to silence those channels too. Empty `platforms` silences all enabled platforms (same as `events.*.platforms`).
80
+ Add `gotify`, `telegram`, or `ntfy` to `platforms` to silence those channels too. Empty `platforms` silences all enabled platforms (same as `events.*.platforms`). Blocking events (`ask_user_prompt`, `permission_request`) are never silenced — see below.
81
+
82
+ ### Re-notify unanswered prompts
83
+
84
+ When a blocking prompt (`ask_user_prompt`, `permission_request`) is not answered, notify re-sends the same notification every `intervalMs`, with the title suffixed `(still waiting)` and priority `high`, up to `maxRepeats` times. Default: **on**, every 2 minutes, 3 repeats. This is the one notify case where missing the push leaves the agent parked indefinitely.
85
+
86
+ ```json
87
+ {
88
+ "renotify": {
89
+ "enabled": true,
90
+ "intervalMs": 120000,
91
+ "maxRepeats": 3
92
+ }
93
+ }
94
+ ```
95
+
96
+ `maxRepeats: 0` sends the initial notification only. Reminders stop as soon as any of these fires: the user presses a key, herdr reports `herdr:blocked` `active: false`, the agent starts a new turn (`agent_start`), or the session ends. Only one prompt can be outstanding at a time — arming a new one replaces the previous reminder. Reminders bypass `silenceAfterInput` because blocking events are exempt from it.
97
+
98
+ Edit in `/unipi:notify-settings` → Re-notify, or in `~/.unipi/config/notify/config.json`.
79
99
 
80
100
  ### Gotify
81
101
 
package/activity.ts CHANGED
@@ -19,6 +19,20 @@ const VALID_PLATFORMS: ReadonlySet<NotifyPlatform> = new Set([
19
19
  "ntfy",
20
20
  ]);
21
21
 
22
+ /**
23
+ * Events where a human is blocking the session. They arrive seconds after the
24
+ * keypress that caused them, so recent input must never mute them.
25
+ */
26
+ const BLOCKING_EVENTS: ReadonlySet<string> = new Set([
27
+ "ask_user_prompt",
28
+ "permission_request",
29
+ ]);
30
+
31
+ /** Whether an event type is a human-blocking prompt. */
32
+ export function isBlockingEvent(eventType: string | undefined): boolean {
33
+ return eventType !== undefined && BLOCKING_EVENTS.has(eventType);
34
+ }
35
+
22
36
  let lastInputAt = 0;
23
37
 
24
38
  /** Record a terminal keypress. `at` is injectable for tests. */
@@ -36,12 +50,19 @@ export function resetInputActivity(): void {
36
50
  * Does not look at platform `enabled` flags — caller filters those first.
37
51
  * Empty `platforms` (while enabled) silences all incoming channels, matching
38
52
  * `events.*.platforms: []` → all enabled.
53
+ *
54
+ * Human-blocking events (`ask_user_prompt`, `permission_request`) bypass the
55
+ * filter entirely — the keypress that triggered them must not mute them.
39
56
  */
40
57
  export function filterPlatformsAfterInput(
41
58
  platforms: NotifyPlatform[],
42
59
  config: Pick<NotifyConfig, "silenceAfterInput">,
43
60
  now: number = Date.now(),
61
+ eventType?: string,
44
62
  ): { send: NotifyPlatform[]; silenced: NotifyPlatform[] } {
63
+ if (isBlockingEvent(eventType)) {
64
+ return { send: platforms.slice(), silenced: [] };
65
+ }
45
66
  const cfg = config.silenceAfterInput;
46
67
  if (!shouldSilence(cfg, now)) {
47
68
  return { send: platforms.slice(), silenced: [] };
package/events.ts CHANGED
@@ -16,7 +16,7 @@ import { sendNtfyNotification } from "./platforms/ntfy.js";
16
16
  import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
17
17
  import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
18
18
  import { summarizeLastMessage } from "./summarize.js";
19
- import { filterPlatformsAfterInput } from "./activity.js";
19
+ import { filterPlatformsAfterInput, isBlockingEvent } from "./activity.js";
20
20
 
21
21
  // Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
22
22
  // Keep this as a local string until that package publishes an importable
@@ -30,14 +30,107 @@ const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt" as const;
30
30
  // third-party package rather than the unipi event contract.
31
31
  const PERMISSION_UI_PROMPT_EVENT = "permissions:ui_prompt" as const;
32
32
 
33
+ /** Minimal shape of the background-tasks shared registry (optional sibling package). */
34
+ type SharedTaskRegistryLike = {
35
+ allTasks(): ReadonlyArray<{ status?: string; triggerOnCompletion?: boolean }>;
36
+ };
37
+
38
+ /** Symbol @pi-unipi/background-tasks publishes its live registry under. */
39
+ const SHARED_REGISTRY_KEY = Symbol.for("unipi.background-tasks.shared-registry");
40
+
41
+ /**
42
+ * True when a background task will wake the agent with its own follow-up turn.
43
+ * Reads the shared globalThis symbol directly rather than importing
44
+ * @pi-unipi/background-tasks, so notify has zero load-order or dependency
45
+ * coupling to that optional sibling. Any read failure means "no pending wake".
46
+ */
47
+ export function hasPendingWakeTask(): boolean {
48
+ try {
49
+ const registry = (globalThis as Record<symbol, unknown>)[SHARED_REGISTRY_KEY] as
50
+ | SharedTaskRegistryLike
51
+ | undefined;
52
+ if (typeof registry?.allTasks !== "function") return false;
53
+ const tasks = registry.allTasks();
54
+ return Array.isArray(tasks)
55
+ ? tasks.some((task) => task.status === "running" && task.triggerOnCompletion === true)
56
+ : false;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Default dispatch priority for an event type, when the event path sets one. */
63
+ function defaultEventPriority(eventKey: string): NotifyPriority | undefined {
64
+ if (isBlockingEvent(eventKey)) return "high";
65
+ if (isAgentNotificationEvent(eventKey)) return "low";
66
+ return undefined;
67
+ }
68
+
33
69
  /** Stored session context for modelRegistry access */
34
70
  let sessionCtx: ExtensionContext | null = null;
35
71
 
36
72
  /** Unsubscribe functions for pi.events.on() listeners. Cleared before each registration to avoid accumulation across reloads. */
37
73
  const unsubs: Array<() => void> = [];
38
74
 
75
+ /** Pending re-notify interval for an unanswered blocking prompt. */
76
+ let renotifyTimer: ReturnType<typeof setInterval> | undefined;
77
+
78
+ /** Cancel any pending re-notify timer. Safe to call at any time. */
79
+ export function disarmRenotify(): void {
80
+ const timer = renotifyTimer;
81
+ renotifyTimer = undefined;
82
+ if (timer === undefined) return;
83
+ try {
84
+ clearInterval(timer);
85
+ } catch {
86
+ // Timer already gone (e.g. after a reload) — nothing to clear.
87
+ }
88
+ }
89
+
90
+ /**
91
+ * (Re)arm the reminder loop for a blocking prompt. Only one prompt can be
92
+ * outstanding at a time, so arming replaces any existing timer rather than
93
+ * stacking a second one.
94
+ */
95
+ function armRenotify(
96
+ pi: ExtensionAPI,
97
+ title: string,
98
+ message: string,
99
+ platforms: NotifyPlatform[],
100
+ eventType: string,
101
+ config: NotifyConfig,
102
+ cwd: string,
103
+ dispatch: DispatchNotification,
104
+ ): void {
105
+ disarmRenotify();
106
+ const { enabled, intervalMs, maxRepeats } = config.renotify;
107
+ if (!enabled || maxRepeats <= 0) return;
108
+
109
+ let fired = 0;
110
+ const timer = setInterval(() => {
111
+ fired += 1;
112
+ dispatch(
113
+ pi,
114
+ `${title} (still waiting)`,
115
+ message,
116
+ platforms,
117
+ eventType,
118
+ config,
119
+ cwd,
120
+ "high"
121
+ ).catch(() => {
122
+ // Silently ignore — background notification failure is non-blocking.
123
+ });
124
+ if (fired >= maxRepeats) disarmRenotify();
125
+ }, intervalMs);
126
+ // Never hold the process open for a reminder. (undefined-safe for mocked timers.)
127
+ timer.unref?.();
128
+ renotifyTimer = timer;
129
+ }
130
+
39
131
  /** Unregister all previously registered pi.events.on() listeners. */
40
132
  function unregisterAll(): void {
133
+ disarmRenotify();
41
134
  for (const unsub of unsubs) {
42
135
  try { unsub(); } catch { /* ignore */ }
43
136
  }
@@ -83,7 +176,8 @@ const LIFECYCLE_EVENTS = new Set(["agent_end", "agent_settled", "session_shutdow
83
176
  export function registerEventListeners(
84
177
  pi: ExtensionAPI,
85
178
  config: NotifyConfig,
86
- cwd: string
179
+ cwd: string,
180
+ dispatch: DispatchNotification = dispatchNotification
87
181
  ): void {
88
182
  // Remove all previously registered EventBus listeners to prevent accumulation
89
183
  // across reloads (EventBus persists but module instances are replaced).
@@ -99,11 +193,21 @@ export function registerEventListeners(
99
193
  const title = `Pi — ${def.label}`;
100
194
  const message = buildEventMessage(eventKey, payload);
101
195
  // Fire-and-forget: don't block the event emitter
102
- dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
103
- () => {
104
- // Silently ignore — background notification failure is non-blocking.
105
- }
106
- );
196
+ dispatch(
197
+ pi,
198
+ title,
199
+ message,
200
+ eventConfig.platforms,
201
+ eventKey,
202
+ config,
203
+ cwd,
204
+ defaultEventPriority(eventKey)
205
+ ).catch(() => {
206
+ // Silently ignore — background notification failure is non-blocking.
207
+ });
208
+ if (isBlockingEvent(eventKey)) {
209
+ armRenotify(pi, title, message, eventConfig.platforms, eventKey, config, cwd, dispatch);
210
+ }
107
211
  };
108
212
 
109
213
  // Pi lifecycle events are dispatched via ExtensionRunner — must use
@@ -123,16 +227,26 @@ export function registerEventListeners(
123
227
  unsubs.push(pi.events.on(ASK_USER_PROMPT_EVENT, (payload: unknown) => {
124
228
  const title = `Pi — ${BUILTIN_EVENTS.ask_user_prompt.label}`;
125
229
  const message = buildAskUserPromptMessage(payload);
126
- dispatchNotification(pi, title, message, askUserConfig.platforms, "ask_user_prompt", config, cwd).catch(
230
+ dispatch(pi, title, message, askUserConfig.platforms, "ask_user_prompt", config, cwd, "high").catch(
127
231
  () => {
128
232
  // Silently ignore — background notification failure is non-blocking.
129
233
  }
130
234
  );
235
+ armRenotify(pi, title, message, askUserConfig.platforms, "ask_user_prompt", config, cwd, dispatch);
131
236
  }));
132
237
  }
133
238
 
134
- registerAgentNotification(pi, "agent_end", config, cwd);
135
- registerAgentNotification(pi, "agent_settled", config, cwd);
239
+ // A reminder loop must never outlive the prompt it is nagging about: any of
240
+ // these signals means the human acted or the agent moved on.
241
+ unsubs.push(pi.events.on("herdr:blocked", (payload: unknown) => {
242
+ if ((payload as { active?: unknown } | null)?.active === false) disarmRenotify();
243
+ }));
244
+ (pi as any).on("agent_start", () => {
245
+ disarmRenotify();
246
+ });
247
+
248
+ registerAgentNotification(pi, "agent_end", config, cwd, dispatch);
249
+ registerAgentNotification(pi, "agent_settled", config, cwd, dispatch);
136
250
  }
137
251
 
138
252
  /** Get all platforms that are currently enabled in config */
@@ -150,6 +264,9 @@ export function unregisterEventListeners(): void {
150
264
  unregisterAll();
151
265
  }
152
266
 
267
+ /** Dispatcher signature — injectable so tests can observe calls without sending. */
268
+ export type DispatchNotification = typeof dispatchNotification;
269
+
153
270
  /**
154
271
  * Dispatch a notification to the configured platforms.
155
272
  * Sends to all specified platforms (or defaults) in parallel.
@@ -184,7 +301,7 @@ export async function dispatchNotification(
184
301
  });
185
302
 
186
303
  const { send: platformsToSend, silenced: inputSilenced } =
187
- filterPlatformsAfterInput(enabledPlatforms, config);
304
+ filterPlatformsAfterInput(enabledPlatforms, config, Date.now(), eventType);
188
305
 
189
306
  const results = await Promise.all(
190
307
  platformsToSend.map(async (platform) => {
@@ -327,12 +444,18 @@ function registerAgentNotification(
327
444
  pi: ExtensionAPI,
328
445
  eventKey: "agent_end" | "agent_settled",
329
446
  config: NotifyConfig,
330
- cwd: string
447
+ cwd: string,
448
+ dispatch: DispatchNotification = dispatchNotification
331
449
  ): void {
332
450
  const eventConfig = config.events[eventKey];
333
451
  if (!eventConfig?.enabled) return;
334
452
 
335
453
  const handler = (payload: unknown) => {
454
+ // A running background task with triggerOnCompletion wakes the agent in a
455
+ // fresh turn that produces its own agent_end/agent_settled. Notifying for
456
+ // this intermediate turn as well would duplicate the wake message.
457
+ if (hasPendingWakeTask()) return;
458
+
336
459
  // Fire-and-forget: build message and dispatch in background,
337
460
  // don't block agent lifecycle hooks from completing.
338
461
  const sessionName = pi.getSessionName?.();
@@ -361,7 +484,7 @@ function registerAgentNotification(
361
484
  })
362
485
  .catch(() => buildAgentLifecycleMessage(eventKey, sessionName))
363
486
  .then((message) =>
364
- dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd)
487
+ dispatch(pi, title, message, eventConfig.platforms, eventKey, config, cwd, "low")
365
488
  )
366
489
  .catch(() => {
367
490
  // Silently ignore — background agent notification failure is non-blocking.
@@ -373,7 +496,7 @@ function registerAgentNotification(
373
496
 
374
497
  // No recap or recap unavailable: dispatch immediately in background.
375
498
  const message = buildAgentLifecycleMessage(eventKey, sessionName);
376
- dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
499
+ dispatch(pi, title, message, eventConfig.platforms, eventKey, config, cwd, "low").catch(
377
500
  () => {
378
501
  // Silently ignore — background agent notification failure is non-blocking.
379
502
  }
package/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  unregisterEventListeners,
24
24
  setSessionContext,
25
25
  clearSessionContext,
26
+ disarmRenotify,
26
27
  } from "./events.js";
27
28
  import { noteInput, resetInputActivity } from "./activity.js";
28
29
 
@@ -49,6 +50,8 @@ export default function (pi: ExtensionAPI) {
49
50
  try {
50
51
  unsubTerminalInput = onTerminalInput(() => {
51
52
  noteInput();
53
+ // The user is at the keyboard — any outstanding prompt reminder is stale.
54
+ disarmRenotify();
52
55
  });
53
56
  } catch {
54
57
  unsubTerminalInput = undefined;
@@ -70,6 +73,7 @@ export default function (pi: ExtensionAPI) {
70
73
  pi.on("session_shutdown", async () => {
71
74
  unsubTerminalInput?.();
72
75
  unsubTerminalInput = undefined;
76
+ disarmRenotify();
73
77
  resetInputActivity();
74
78
  clearSessionContext();
75
79
  unregisterEventListeners();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.18.0",
3
+ "version": "2.19.1",
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
  "main": "index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.18.0",
37
+ "@pi-unipi/core": "2.19.1",
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "peerDependencies": {
package/settings.ts CHANGED
@@ -9,7 +9,7 @@ import { dirname, join } from "path";
9
9
  import { homedir } from "os";
10
10
  import { NOTIFY_DIRS } from "@pi-unipi/core";
11
11
  import { mergeSilenceAfterInput } from "./activity.js";
12
- import type { NotifyConfig } from "./types.js";
12
+ import type { NotifyConfig, RenotifyConfig } from "./types.js";
13
13
 
14
14
  /** Resolve config path (expands ~ to homedir) */
15
15
  function resolveConfigPath(): string {
@@ -52,6 +52,11 @@ export const DEFAULT_CONFIG: NotifyConfig = {
52
52
  windowMs: 10000,
53
53
  platforms: ["native"],
54
54
  },
55
+ renotify: {
56
+ enabled: true,
57
+ intervalMs: 120000,
58
+ maxRepeats: 3,
59
+ },
55
60
  };
56
61
 
57
62
  /** Load config from disk, returning defaults if missing or invalid */
@@ -132,5 +137,30 @@ function mergeWithDefaults(loaded: Partial<NotifyConfig>): NotifyConfig {
132
137
  loaded.silenceAfterInput,
133
138
  base.silenceAfterInput,
134
139
  ),
140
+ renotify: mergeRenotify(loaded.renotify, base.renotify),
141
+ };
142
+ }
143
+
144
+ /** Merge the renotify block, falling back per-field on invalid scalars. */
145
+ function mergeRenotify(
146
+ loaded: Partial<RenotifyConfig> | undefined,
147
+ defaults: RenotifyConfig,
148
+ ): RenotifyConfig {
149
+ const intervalMs =
150
+ typeof loaded?.intervalMs === "number" &&
151
+ Number.isFinite(loaded.intervalMs) &&
152
+ loaded.intervalMs >= 10000
153
+ ? loaded.intervalMs
154
+ : defaults.intervalMs;
155
+ const maxRepeats =
156
+ typeof loaded?.maxRepeats === "number" &&
157
+ Number.isInteger(loaded.maxRepeats) &&
158
+ loaded.maxRepeats >= 0
159
+ ? loaded.maxRepeats
160
+ : defaults.maxRepeats;
161
+ return {
162
+ enabled: loaded?.enabled ?? defaults.enabled,
163
+ intervalMs,
164
+ maxRepeats,
135
165
  };
136
166
  }
@@ -67,6 +67,11 @@ Help users configure the `@pi-unipi/notify` notification system.
67
67
  "windowMs": 10000,
68
68
  "platforms": ["native"]
69
69
  },
70
+ "renotify": {
71
+ "enabled": true,
72
+ "intervalMs": 120000,
73
+ "maxRepeats": 3
74
+ },
70
75
  "NOTE": "ntfy section is legacy — migrated to ntfy.json on first run"
71
76
  }
72
77
  ```
@@ -95,6 +100,26 @@ Quiet listed platforms for `windowMs` after any terminal keypress. **Default: of
95
100
 
96
101
  TUI: `/unipi:notify-settings` → Platforms → Quiet after activity (Space), ←→ then Space for channels, +/− for the window (1s steps).
97
102
 
103
+ ### Re-notify unanswered prompts (default: enabled)
104
+
105
+ When a blocking prompt (`ask_user_prompt`, `permission_request`) is not answered, notify re-sends the same notification every `intervalMs`, title suffixed `(still waiting)`, priority `high`, up to `maxRepeats` times.
106
+
107
+ ```json
108
+ "renotify": {
109
+ "enabled": true,
110
+ "intervalMs": 120000,
111
+ "maxRepeats": 3
112
+ }
113
+ ```
114
+
115
+ - `enabled` — master switch (default: true)
116
+ - `intervalMs` — delay between reminders in milliseconds, minimum 10000 (default: 120000 = 2 min)
117
+ - `maxRepeats` — reminders after the first notification, 0 sends none (default: 3)
118
+
119
+ Reminders stop as soon as the user presses a key, herdr reports `herdr:blocked` `active: false`, the agent starts a new turn, or the session ends. Arming a new prompt replaces any existing reminder (only one can be outstanding). Reminders bypass `silenceAfterInput` since blocking events are exempt.
120
+
121
+ TUI: `/unipi:notify-settings` → Re-notify → Space toggles enabled, +/− adjusts interval (30s steps) and max repeats.
122
+
98
123
  ### Gotify (default: disabled)
99
124
 
100
125
  Self-hosted push notification server. Requires:
@@ -185,6 +210,8 @@ ntfy uses dedicated `ntfy.json` files at both global and project scope, with ful
185
210
 
186
211
  Each event can override `platforms` — empty array means use `defaultPlatforms`.
187
212
 
213
+ `ask_user_prompt` and `permission_request` are **blocking** events: while one is unanswered the agent is parked, so notify re-sends it periodically (see the Re-notify unanswered prompts section under Platforms).
214
+
188
215
  ### `permission_request`
189
216
 
190
217
  Fires on the `permissions:ui_prompt` broadcast from
@@ -246,3 +273,5 @@ For general settings: suggest `/unipi:notify-settings`
246
273
  - Telegram: `botToken` and `chatId` required when enabled
247
274
  - ntfy: `serverUrl` and `topic` required when enabled
248
275
  - ntfy: `priority` must be 1-5
276
+ - renotify: `intervalMs` must be a finite number >= 10000 (else the default 120000 is used)
277
+ - renotify: `maxRepeats` must be an integer >= 0 (else the default 3 is used)
@@ -18,7 +18,7 @@ import type { NotifyConfig, NotifyPlatform, NtfyConfig } from "../types.js";
18
18
  import { OverlayTheme, boxInnerWidth } from "@pi-unipi/core";
19
19
 
20
20
  /** Section types */
21
- type Section = "platforms" | "events" | "recap";
21
+ type Section = "platforms" | "events" | "recap" | "renotify";
22
22
 
23
23
  const PLATFORM_KEYS: NotifyPlatform[] = ["native", "gotify", "telegram", "ntfy"];
24
24
  const CHIP_LABELS: Record<NotifyPlatform, string> = {
@@ -36,6 +36,22 @@ const WINDOW_STEP_MS = 1_000;
36
36
  const WINDOW_MIN_MS = 1_000;
37
37
  const WINDOW_MAX_MS = 120_000;
38
38
 
39
+ const RENOTIFY_INTERVAL_INDEX = 1;
40
+ const RENOTIFY_MAX_REPEATS_INDEX = 2;
41
+ const RENOTIFY_INTERVAL_STEP_MS = 30_000;
42
+ const RENOTIFY_INTERVAL_MIN_MS = 10_000;
43
+ const RENOTIFY_INTERVAL_MAX_MS = 600_000;
44
+ const RENOTIFY_MAX_REPEATS_MAX = 10;
45
+
46
+ /** Format a re-notify interval as a compact duration label. */
47
+ function formatRenotifyInterval(ms: number): string {
48
+ const seconds = Math.round(ms / 1000);
49
+ if (seconds < 60) return `${seconds}s`;
50
+ const minutes = Math.floor(seconds / 60);
51
+ const rest = seconds % 60;
52
+ return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`;
53
+ }
54
+
39
55
  /**
40
56
  * Settings overlay component.
41
57
  */
@@ -108,12 +124,22 @@ export class NotifySettingsOverlay implements Component {
108
124
  return;
109
125
  }
110
126
  }
127
+ if (this.section === "renotify") {
128
+ if (data === "+" || data === "=") {
129
+ this.nudgeRenotify(1);
130
+ return;
131
+ }
132
+ if (data === "-" || data === "_") {
133
+ this.nudgeRenotify(-1);
134
+ return;
135
+ }
136
+ }
111
137
  if (matchesKey(data, "space")) {
112
138
  this.toggleCurrent();
113
139
  return;
114
140
  }
115
141
  if (matchesKey(data, "tab")) {
116
- const sections: Section[] = ["platforms", "events", "recap"];
142
+ const sections: Section[] = ["platforms", "events", "recap", "renotify"];
117
143
  const idx = sections.indexOf(this.section);
118
144
  this.section = sections[(idx + 1) % sections.length];
119
145
  this.selectedIndex = 0;
@@ -139,6 +165,7 @@ export class NotifySettingsOverlay implements Component {
139
165
  private get maxItems(): number {
140
166
  if (this.section === "platforms") return 7; // 4 platforms + focused + silence master + chips
141
167
  if (this.section === "recap") return 1; // toggle
168
+ if (this.section === "renotify") return 3; // enable + interval + max repeats
142
169
  return Object.keys(this.config.events).length;
143
170
  }
144
171
 
@@ -150,6 +177,21 @@ export class NotifySettingsOverlay implements Component {
150
177
  );
151
178
  }
152
179
 
180
+ private nudgeRenotify(direction: number): void {
181
+ const { renotify } = this.config;
182
+ if (this.selectedIndex === RENOTIFY_INTERVAL_INDEX) {
183
+ renotify.intervalMs = Math.min(
184
+ RENOTIFY_INTERVAL_MAX_MS,
185
+ Math.max(RENOTIFY_INTERVAL_MIN_MS, renotify.intervalMs + direction * RENOTIFY_INTERVAL_STEP_MS),
186
+ );
187
+ } else if (this.selectedIndex === RENOTIFY_MAX_REPEATS_INDEX) {
188
+ renotify.maxRepeats = Math.min(
189
+ RENOTIFY_MAX_REPEATS_MAX,
190
+ Math.max(0, renotify.maxRepeats + direction),
191
+ );
192
+ }
193
+ }
194
+
153
195
  private chipOn(key: NotifyPlatform): boolean {
154
196
  const listed = this.config.silenceAfterInput.platforms;
155
197
  if (listed.length === 0) return true;
@@ -191,6 +233,10 @@ export class NotifySettingsOverlay implements Component {
191
233
  }
192
234
  } else if (this.section === "recap") {
193
235
  this.config.recap.enabled = !this.config.recap.enabled;
236
+ } else if (this.section === "renotify") {
237
+ if (this.selectedIndex === 0) {
238
+ this.config.renotify.enabled = !this.config.renotify.enabled;
239
+ }
194
240
  } else {
195
241
  const eventKeys = Object.keys(this.config.events);
196
242
  const key = eventKeys[this.selectedIndex];
@@ -238,13 +284,19 @@ export class NotifySettingsOverlay implements Component {
238
284
  this.section === "recap"
239
285
  ? this.overlay.fg("accent", this.overlay.bold("[Recap]"))
240
286
  : this.overlay.fg("dim", "Recap");
241
- lines.push(this.overlay.frameLine(` ${platformTab} ${eventsTab} ${recapTab}`, innerWidth));
287
+ const renotifyTab =
288
+ this.section === "renotify"
289
+ ? this.overlay.fg("accent", this.overlay.bold("[Re-notify]"))
290
+ : this.overlay.fg("dim", "Re-notify");
291
+ lines.push(this.overlay.frameLine(` ${platformTab} ${eventsTab} ${recapTab} ${renotifyTab}`, innerWidth));
242
292
  lines.push(this.overlay.ruleLine(innerWidth));
243
293
 
244
294
  if (this.section === "platforms") {
245
295
  this.renderPlatforms(lines, innerWidth);
246
296
  } else if (this.section === "recap") {
247
297
  this.renderRecap(lines, innerWidth);
298
+ } else if (this.section === "renotify") {
299
+ this.renderRenotify(lines, innerWidth);
248
300
  } else {
249
301
  this.renderEvents(lines, innerWidth);
250
302
  }
@@ -271,6 +323,9 @@ export class NotifySettingsOverlay implements Component {
271
323
  if (this.section === "recap") {
272
324
  return "↑↓ navigate · Space toggle · M change model · Tab switch · Enter save · Esc cancel";
273
325
  }
326
+ if (this.section === "renotify") {
327
+ return "↑↓ navigate · Space toggle · +/− adjust · Tab switch · Enter save · Esc cancel";
328
+ }
274
329
  if (this.section === "platforms" && this.selectedIndex === SILENCE_MASTER_INDEX) {
275
330
  return "↑↓ navigate · Space toggle · +/− window · Tab switch · Enter save · Esc cancel";
276
331
  }
@@ -449,4 +504,50 @@ export class NotifySettingsOverlay implements Component {
449
504
  )
450
505
  );
451
506
  }
507
+
508
+ private renderRenotify(lines: string[], innerWidth: number): void {
509
+ const toggleOn = this.overlay.fg("success", "●");
510
+ const toggleOff = this.overlay.fg("dim", "○");
511
+ const rows: Array<{ label: string; detail: string; toggle?: boolean }> = [
512
+ {
513
+ label: "Enable Re-notify",
514
+ detail: "Remind while a question or permission prompt is unanswered",
515
+ toggle: this.config.renotify.enabled,
516
+ },
517
+ {
518
+ label: "Interval",
519
+ detail: formatRenotifyInterval(this.config.renotify.intervalMs),
520
+ },
521
+ {
522
+ label: "Max repeats",
523
+ detail: `${this.config.renotify.maxRepeats}`,
524
+ },
525
+ ];
526
+
527
+ for (let i = 0; i < rows.length; i++) {
528
+ const row = rows[i];
529
+ if (!row) continue;
530
+ const isSelected = i === this.selectedIndex;
531
+ const toggle = row.toggle === undefined ? " " : row.toggle ? toggleOn : toggleOff;
532
+ const label = isSelected
533
+ ? this.overlay.bold(row.label)
534
+ : this.overlay.fg("dim", row.label);
535
+ const detail =
536
+ this.config.renotify.enabled || i === 0
537
+ ? this.overlay.fg("dim", row.detail)
538
+ : this.overlay.fg("dim", "—");
539
+ lines.push(
540
+ this.overlay.frameLine(
541
+ `${isSelected ? this.overlay.fg("accent", "▸") : " "} ${toggle} ${label} ${detail}`,
542
+ innerWidth,
543
+ ),
544
+ );
545
+ }
546
+ lines.push(
547
+ this.overlay.frameLine(
548
+ this.overlay.fg("dim", " Blocking prompts only (ask_user, permission_request)"),
549
+ innerWidth,
550
+ ),
551
+ );
552
+ }
452
553
  }
package/types.ts CHANGED
@@ -90,6 +90,16 @@ export interface SilenceAfterInputConfig {
90
90
  platforms: NotifyPlatform[];
91
91
  }
92
92
 
93
+ /** Re-send an unanswered human-blocking prompt until someone acts */
94
+ export interface RenotifyConfig {
95
+ /** Master switch */
96
+ enabled: boolean;
97
+ /** Delay between reminders, in milliseconds */
98
+ intervalMs: number;
99
+ /** Reminders to send after the first notification (0 = none) */
100
+ maxRepeats: number;
101
+ }
102
+
93
103
  /** Full notification configuration */
94
104
  export interface NotifyConfig {
95
105
  /** Global default platforms for all events */
@@ -106,6 +116,8 @@ export interface NotifyConfig {
106
116
  recap: RecapConfig;
107
117
  /** Suppress listed platforms after recent terminal input */
108
118
  silenceAfterInput: SilenceAfterInputConfig;
119
+ /** Re-notify unanswered human-blocking prompts */
120
+ renotify: RenotifyConfig;
109
121
  }
110
122
 
111
123
  /** Parameters for the notify_user agent tool */