@pi-unipi/unipi 2.18.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/CHANGELOG.md +29 -0
- package/package.json +24 -24
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/README.md +3 -3
- package/packages/btw/extensions/btw.ts +3 -2
- package/packages/btw/package.json +2 -2
- package/packages/btw/skills/btw/SKILL.md +2 -0
- package/packages/compactor/package.json +3 -3
- package/packages/core/package.json +1 -1
- package/packages/footer/package.json +3 -3
- package/packages/fusion/README.md +5 -2
- package/packages/fusion/package.json +3 -3
- package/packages/fusion/src/picker.ts +61 -30
- package/packages/fusion/src/sidekick-runtime.ts +62 -19
- package/packages/fusion/src/tools.ts +11 -3
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +21 -1
- package/packages/notify/activity.ts +21 -0
- package/packages/notify/events.ts +137 -14
- package/packages/notify/index.ts +4 -0
- package/packages/notify/package.json +2 -2
- package/packages/notify/settings.ts +31 -1
- package/packages/notify/skills/configure-notify/SKILL.md +29 -0
- package/packages/notify/tui/settings-overlay.ts +104 -3
- package/packages/notify/types.ts +12 -0
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/agents/reviewer.md +1 -0
- package/packages/subagents/package.json +2 -2
- package/packages/subagents/src/index.ts +2 -0
- package/packages/subagents/src/pi-args.ts +6 -0
- package/packages/trajectory/package.json +1 -1
- package/packages/unipi/bundled.js +9034 -2642
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/list-detail-overlay.ts +12 -8
- package/packages/updater/src/tui/readme-overlay.ts +2 -2
- package/packages/updater/src/tui/update-overlay.ts +30 -25
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
|
@@ -60,7 +60,7 @@ async function waitForReport(
|
|
|
60
60
|
ctx: ExtensionContext,
|
|
61
61
|
onUpdate?: (update: unknown) => void,
|
|
62
62
|
timeoutMs = 2700000,
|
|
63
|
-
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean }> {
|
|
63
|
+
): Promise<{ report?: HandoffReport; interrupted?: string; aborted?: boolean; error?: string }> {
|
|
64
64
|
const started = Date.now();
|
|
65
65
|
let lastProgressKey = "";
|
|
66
66
|
while (true) {
|
|
@@ -72,8 +72,14 @@ async function waitForReport(
|
|
|
72
72
|
const remaining = timeoutMs - (Date.now() - started);
|
|
73
73
|
if (remaining <= 0) return {};
|
|
74
74
|
const timer = new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), Math.min(500, remaining)));
|
|
75
|
-
const
|
|
76
|
-
|
|
75
|
+
const outcome = await Promise.race([
|
|
76
|
+
done.then((report) => ({ report }), (error) => ({ error: error instanceof Error ? error.message : String(error) })),
|
|
77
|
+
timer,
|
|
78
|
+
]);
|
|
79
|
+
if (outcome !== undefined) {
|
|
80
|
+
if ("error" in outcome) return { error: outcome.error };
|
|
81
|
+
return { report: outcome.report };
|
|
82
|
+
}
|
|
77
83
|
const progress = progressText(runtime, id);
|
|
78
84
|
const key = progressKey(runtime, id);
|
|
79
85
|
if (key !== lastProgressKey) {
|
|
@@ -175,6 +181,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
175
181
|
deps.onReport?.(ctx, waited.report);
|
|
176
182
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
177
183
|
}
|
|
184
|
+
if (waited.error) return result(`Handoff ${handoff.id} failed: ${waited.error}`, undefined, true);
|
|
178
185
|
if (waited.aborted) return result(`${progressText(runtime, handoff.id)}\nHandoff ${handoff.id} aborted.`, undefined, true);
|
|
179
186
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${handoff.id}) was working. The handoff continues in the background. Act on the user's message first, then call read_subagent({agent_id:"${handoff.id}", block:true}) to collect the report or sidekick({message}) to redirect it.\n${waited.interrupted}`);
|
|
180
187
|
return result(`Handoff ${handoff.id} is still running.\n${progressText(runtime, handoff.id)}`);
|
|
@@ -205,6 +212,7 @@ export function registerFusionTools(pi: ExtensionAPI, deps: FusionToolDeps): voi
|
|
|
205
212
|
deps.onReport?.(ctx, waited.report);
|
|
206
213
|
return result(reportText(waited.report), waited.report, waited.report.status !== "completed");
|
|
207
214
|
}
|
|
215
|
+
if (waited.error) return result(`Handoff ${id} failed: ${waited.error}`, undefined, true);
|
|
208
216
|
if (waited.aborted) return result(`Handoff ${id} aborted.`, undefined, true);
|
|
209
217
|
if (waited.interrupted) return result(`A user message arrived while the sidekick (agent_id ${id}) was working.\n${waited.interrupted}`);
|
|
210
218
|
return result(`Handoff ${id} is still running.\n${progressText(runtime, id)}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/image",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Image generation and image recognition tools for the Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/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
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"@earendil-works/pi-ai": "^0.84.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/info-screen",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Dashboard and module registry for Unipi — configurable info overlay with tabbed groups",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.19.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/input-shortcuts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Keyboard shortcuts for stash/restore, undo/redo, clipboard, and thinking toggle — chord-based overlay system",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"access": "public"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@pi-unipi/core": "2.
|
|
36
|
+
"@pi-unipi/core": "2.19.0"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
39
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/kanboard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Visualization layer for unipi workflow — HTTP server with htmx/Alpine.js UI, modular parsers, TUI overlay, and kanban board",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "2.
|
|
42
|
+
"@pi-unipi/core": "2.19.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "MCP server management extension for Pi coding agent — browse, add, configure, and use MCP servers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "npx tsx --test tests/**/*.test.ts"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@pi-unipi/core": "2.
|
|
33
|
+
"@pi-unipi/core": "2.19.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Persistent cross-session memory with MemPalace backend (auto-installed) and SQLite fallback for Pi coding agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
"README.md"
|
|
40
40
|
],
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@pi-unipi/core": "2.
|
|
43
|
-
"@pi-unipi/info-screen": "2.
|
|
42
|
+
"@pi-unipi/core": "2.19.0",
|
|
43
|
+
"@pi-unipi/info-screen": "2.19.0",
|
|
44
44
|
"js-yaml": "^4.1.0"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/milestone",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "2.
|
|
35
|
+
"@pi-unipi/core": "2.19.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
@@ -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
|
|
|
@@ -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: [] };
|
|
@@ -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/packages/notify/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();
|
|
@@ -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": {
|
|
@@ -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)
|