@pi-unipi/notify 2.17.0 → 2.19.0
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 +21 -1
- package/activity.ts +21 -0
- package/events.ts +137 -14
- package/index.ts +4 -0
- package/package.json +2 -2
- package/settings.ts +31 -1
- package/skills/configure-notify/SKILL.md +29 -0
- package/tui/settings-overlay.ts +104 -3
- package/types.ts +12 -0
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.19.0",
|
|
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.
|
|
37
|
+
"@pi-unipi/core": "2.19.0",
|
|
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)
|
package/tui/settings-overlay.ts
CHANGED
|
@@ -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
|
-
|
|
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 */
|