@pi-unipi/unipi 2.1.2 → 2.2.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 +6 -2
- package/package.json +24 -23
- package/packages/ask-user/ask-ui.ts +45 -31
- package/packages/ask-user/launcher-ui.ts +26 -22
- package/packages/ask-user/package.json +3 -3
- package/packages/ask-user/settings-tui.ts +22 -9
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +5 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +4 -4
- package/packages/compactor/src/tui/settings-overlay.ts +2 -1
- package/packages/core/constants.ts +18 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/tui-width.ts +127 -0
- package/packages/footer/package.json +3 -3
- package/packages/footer/src/tui/settings-tui.ts +2 -1
- package/packages/image/README.md +82 -0
- package/packages/image/index.ts +1 -0
- package/packages/image/package.json +57 -0
- package/packages/image/skills/image/SKILL.md +73 -0
- package/packages/image/src/commands.ts +17 -0
- package/packages/image/src/generate.ts +201 -0
- package/packages/image/src/image-source.ts +204 -0
- package/packages/image/src/index.ts +99 -0
- package/packages/image/src/models.ts +290 -0
- package/packages/image/src/recognize.ts +223 -0
- package/packages/image/src/settings.ts +149 -0
- package/packages/image/src/tools.ts +296 -0
- package/packages/image/src/tui/model-selector.ts +279 -0
- package/packages/image/src/tui/settings-dialog.ts +236 -0
- package/packages/info-screen/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +3 -2
- package/packages/input-shortcuts/package.json +3 -3
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/mcp/src/tui/add-overlay.ts +10 -7
- package/packages/mcp/src/tui/settings-overlay.ts +10 -7
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +4 -1
- package/packages/notify/events.ts +130 -81
- package/packages/notify/package.json +3 -3
- package/packages/notify/permission-prompt-message.ts +95 -0
- package/packages/notify/settings.ts +2 -0
- package/packages/notify/skills/configure-notify/SKILL.md +31 -2
- package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
- package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
- package/packages/notify/tui/gotify-setup.ts +2 -1
- package/packages/notify/tui/ntfy-setup.ts +2 -1
- package/packages/notify/tui/recap-model-selector.ts +2 -2
- package/packages/notify/tui/settings-overlay.ts +2 -1
- package/packages/notify/tui/telegram-setup.ts +2 -1
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +5 -5
- package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
- package/packages/subagents/src/index.ts +52 -10
- package/packages/unipi/index.ts +2 -0
- package/packages/updater/package.json +3 -3
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/readme-overlay.ts +2 -1
- package/packages/updater/src/tui/update-overlay.ts +2 -1
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
- package/packages/utility/src/tui/util-settings-tui.ts +2 -2
- package/packages/web-api/README.md +28 -11
- package/packages/web-api/package.json +8 -2
- package/packages/web-api/skills/web/SKILL.md +36 -9
- package/packages/web-api/src/index.ts +15 -0
- package/packages/web-api/src/providers/duckduckgo.ts +71 -27
- package/packages/web-api/src/providers/firecrawl.ts +1 -1
- package/packages/web-api/src/providers/jina-reader.ts +1 -1
- package/packages/web-api/src/providers/jina-search.ts +1 -1
- package/packages/web-api/src/providers/perplexity.ts +2 -2
- package/packages/web-api/src/providers/serpapi.ts +1 -1
- package/packages/web-api/src/providers/tavily.ts +1 -1
- package/packages/web-api/src/providers/wigolo-client.ts +196 -0
- package/packages/web-api/src/providers/wigolo.ts +117 -0
- package/packages/web-api/src/settings.ts +1 -0
- package/packages/web-api/src/tools.ts +93 -36
- package/packages/workflow/package.json +2 -2
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
getGlobalConfigDir,
|
|
17
17
|
getProjectConfigDir,
|
|
18
18
|
} from "../config/manager.js";
|
|
19
|
+
import { boxInnerWidth, normalizeWidth, WidthKeyedCache } from "@pi-unipi/core";
|
|
19
20
|
|
|
20
21
|
/** Server display item */
|
|
21
22
|
interface ServerDisplayItem {
|
|
@@ -65,7 +66,8 @@ export function renderMcpSettingsOverlay(params?: {
|
|
|
65
66
|
confirmDelete: null,
|
|
66
67
|
};
|
|
67
68
|
|
|
68
|
-
|
|
69
|
+
// Width-keyed so a terminal resize can never serve stale, over-wide lines.
|
|
70
|
+
const lineCache = new WidthKeyedCache();
|
|
69
71
|
|
|
70
72
|
function refreshServers() {
|
|
71
73
|
const configDir =
|
|
@@ -115,7 +117,7 @@ export function renderMcpSettingsOverlay(params?: {
|
|
|
115
117
|
refreshServers();
|
|
116
118
|
|
|
117
119
|
function refresh() {
|
|
118
|
-
|
|
120
|
+
lineCache.clear();
|
|
119
121
|
tui.requestRender();
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -275,11 +277,13 @@ export function renderMcpSettingsOverlay(params?: {
|
|
|
275
277
|
return content + " ".repeat(pad);
|
|
276
278
|
}
|
|
277
279
|
|
|
278
|
-
function render(
|
|
279
|
-
|
|
280
|
+
function render(rawWidth: number): string[] {
|
|
281
|
+
const width = normalizeWidth(rawWidth);
|
|
282
|
+
const cached = lineCache.get(width);
|
|
283
|
+
if (cached) return cached;
|
|
280
284
|
|
|
281
285
|
const lines: string[] = [];
|
|
282
|
-
const innerWidth =
|
|
286
|
+
const innerWidth = boxInnerWidth(width);
|
|
283
287
|
|
|
284
288
|
// ── Header ──────────────────────────────────────────────────────
|
|
285
289
|
const header = " MCP Settings ";
|
|
@@ -356,8 +360,7 @@ export function renderMcpSettingsOverlay(params?: {
|
|
|
356
360
|
);
|
|
357
361
|
lines.push(theme.fg("accent", `╰${"─".repeat(innerWidth)}╯`));
|
|
358
362
|
|
|
359
|
-
|
|
360
|
-
return lines;
|
|
363
|
+
return lineCache.set(width, lines);
|
|
361
364
|
}
|
|
362
365
|
|
|
363
366
|
return { render, invalidate: refresh, handleInput };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/memory",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"better-sqlite3": "^12.9.0",
|
|
44
44
|
"sqlite-vec": "^0.1.9",
|
|
45
45
|
"js-yaml": "^4.1.0",
|
|
46
|
-
"@pi-unipi/core": "2.
|
|
47
|
-
"@pi-unipi/info-screen": "2.
|
|
46
|
+
"@pi-unipi/core": "2.2.0",
|
|
47
|
+
"@pi-unipi/info-screen": "2.2.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/milestone",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"access": "public"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@pi-unipi/core": "2.
|
|
32
|
+
"@pi-unipi/core": "2.2.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@earendil-works/pi-coding-agent": "^0.80.0",
|
|
@@ -24,9 +24,12 @@ Notify subscribes to Pi lifecycle events and routes notifications based on your
|
|
|
24
24
|
| `workflow_end` | On | Workflow command completes |
|
|
25
25
|
| `ralph_loop_end` | On | Ralph loop completes |
|
|
26
26
|
| `mcp_server_error` | On | MCP server error |
|
|
27
|
-
| `agent_end` | Off |
|
|
27
|
+
| `agent_end` | Off | Low-level agent run ends (may fire again on retries) |
|
|
28
|
+
| `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
|
|
28
29
|
| `memory_consolidated` | Off | Memory auto-saved |
|
|
29
30
|
| `session_shutdown` | Off | Session ends |
|
|
31
|
+
| `ask_user_prompt` | Off | Agent asked a question and is waiting for an answer |
|
|
32
|
+
| `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)) |
|
|
30
33
|
|
|
31
34
|
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.
|
|
32
35
|
|
|
@@ -14,6 +14,7 @@ import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
|
14
14
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
15
15
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
16
16
|
import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
|
|
17
|
+
import { buildPermissionPromptMessage } from "./permission-prompt-message.js";
|
|
17
18
|
import { summarizeLastMessage } from "./summarize.js";
|
|
18
19
|
|
|
19
20
|
// Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
|
|
@@ -21,6 +22,13 @@ import { summarizeLastMessage } from "./summarize.js";
|
|
|
21
22
|
// `./events` contract in npm.
|
|
22
23
|
const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt" as const;
|
|
23
24
|
|
|
25
|
+
// Event emitted by @gotgenes/pi-permission-system immediately before the
|
|
26
|
+
// user-facing permission UI is invoked. Fires only for prompts a human must
|
|
27
|
+
// answer — policy auto-allow/deny and session approvals do not emit it.
|
|
28
|
+
// Kept as a local string (like the rpiv event above) because it belongs to a
|
|
29
|
+
// third-party package rather than the unipi event contract.
|
|
30
|
+
const PERMISSION_UI_PROMPT_EVENT = "permissions:ui_prompt" as const;
|
|
31
|
+
|
|
24
32
|
/** Stored session context for modelRegistry access */
|
|
25
33
|
let sessionCtx: ExtensionContext | null = null;
|
|
26
34
|
|
|
@@ -50,20 +58,22 @@ export const BUILTIN_EVENTS: Record<
|
|
|
50
58
|
string,
|
|
51
59
|
{ hook: string; label: string }
|
|
52
60
|
> = {
|
|
53
|
-
agent_end: { hook: "agent_end", label: "Agent Complete" },
|
|
61
|
+
agent_end: { hook: "agent_end", label: "Agent Run Complete" },
|
|
62
|
+
agent_settled: { hook: "agent_settled", label: "Agent Complete" },
|
|
54
63
|
workflow_end: { hook: UNIPI_EVENTS.WORKFLOW_END, label: "Workflow Done" },
|
|
55
64
|
ralph_loop_end: { hook: UNIPI_EVENTS.RALPH_LOOP_END, label: "Ralph Complete" },
|
|
56
65
|
mcp_server_error: { hook: UNIPI_EVENTS.MCP_SERVER_ERROR, label: "MCP Error" },
|
|
57
66
|
memory_consolidated: { hook: UNIPI_EVENTS.MEMORY_CONSOLIDATED, label: "Memory Saved" },
|
|
58
67
|
session_shutdown: { hook: "session_shutdown", label: "Session End" },
|
|
59
68
|
ask_user_prompt: { hook: UNIPI_EVENTS.ASK_USER_PROMPT, label: "Question Asked" },
|
|
69
|
+
permission_request: { hook: PERMISSION_UI_PROMPT_EVENT, label: "Permission Request" },
|
|
60
70
|
};
|
|
61
71
|
|
|
62
72
|
/**
|
|
63
73
|
* Pi lifecycle event types (dispatched by ExtensionRunner).
|
|
64
74
|
* These must use pi.on() — not pi.events.on() — to receive events.
|
|
65
75
|
*/
|
|
66
|
-
const LIFECYCLE_EVENTS = new Set(["agent_end", "session_shutdown"]);
|
|
76
|
+
const LIFECYCLE_EVENTS = new Set(["agent_end", "agent_settled", "session_shutdown"]);
|
|
67
77
|
|
|
68
78
|
/**
|
|
69
79
|
* Register event listeners for all enabled notification events.
|
|
@@ -77,9 +87,9 @@ export function registerEventListeners(
|
|
|
77
87
|
// Remove all previously registered EventBus listeners to prevent accumulation
|
|
78
88
|
// across reloads (EventBus persists but module instances are replaced).
|
|
79
89
|
unregisterAll();
|
|
80
|
-
// Register built-in events (except
|
|
90
|
+
// Register built-in events (except agent lifecycle notifications which have custom logic)
|
|
81
91
|
for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
|
|
82
|
-
if (eventKey
|
|
92
|
+
if (isAgentNotificationEvent(eventKey)) continue; // handled separately below
|
|
83
93
|
|
|
84
94
|
const eventConfig = config.events[eventKey];
|
|
85
95
|
if (!eventConfig?.enabled) continue;
|
|
@@ -95,8 +105,8 @@ export function registerEventListeners(
|
|
|
95
105
|
);
|
|
96
106
|
};
|
|
97
107
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
108
|
+
// Pi lifecycle events are dispatched via ExtensionRunner — must use
|
|
109
|
+
// pi.on(). These are stored in
|
|
100
110
|
// extension.handlers and automatically replaced on reload, so they
|
|
101
111
|
// do NOT accumulate like EventBus listeners.
|
|
102
112
|
if (LIFECYCLE_EVENTS.has(eventKey)) {
|
|
@@ -120,55 +130,8 @@ export function registerEventListeners(
|
|
|
120
130
|
}));
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
if (agentEndConfig?.enabled) {
|
|
126
|
-
const handler = (payload: unknown) => {
|
|
127
|
-
// Fire-and-forget: build message and dispatch in background,
|
|
128
|
-
// don't block agent_end from completing
|
|
129
|
-
const sessionName = pi.getSessionName?.();
|
|
130
|
-
const title = `Pi — ${BUILTIN_EVENTS.agent_end.label}`;
|
|
131
|
-
|
|
132
|
-
if (config.recap.enabled) {
|
|
133
|
-
// Recap mode: summarize asynchronously, then dispatch
|
|
134
|
-
const lastText = extractLastAssistantText(payload);
|
|
135
|
-
if (lastText && sessionCtx?.modelRegistry) {
|
|
136
|
-
const provider = extractProvider(config.recap.model);
|
|
137
|
-
const modelId = extractModelId(config.recap.model);
|
|
138
|
-
const model = sessionCtx.modelRegistry.find(provider, modelId);
|
|
139
|
-
if (model) {
|
|
140
|
-
sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
|
|
141
|
-
.then((apiKeyResult) => {
|
|
142
|
-
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
143
|
-
if (apiKey) {
|
|
144
|
-
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
|
|
145
|
-
.then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
|
|
146
|
-
}
|
|
147
|
-
return buildAgentEndMessage(sessionName);
|
|
148
|
-
})
|
|
149
|
-
.catch(() => buildAgentEndMessage(sessionName))
|
|
150
|
-
.then((message) =>
|
|
151
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd)
|
|
152
|
-
)
|
|
153
|
-
.catch(() => {
|
|
154
|
-
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
155
|
-
});
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// No recap or recap unavailable: dispatch immediately in background
|
|
162
|
-
const message = buildAgentEndMessage(sessionName);
|
|
163
|
-
dispatchNotification(pi, title, message, agentEndConfig.platforms, "agent_end", config, cwd).catch(
|
|
164
|
-
() => {
|
|
165
|
-
// Silently ignore — background agent_end notification failure is non-blocking.
|
|
166
|
-
}
|
|
167
|
-
);
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
(pi as any).on("agent_end", handler);
|
|
171
|
-
}
|
|
133
|
+
registerAgentNotification(pi, "agent_end", config, cwd);
|
|
134
|
+
registerAgentNotification(pi, "agent_settled", config, cwd);
|
|
172
135
|
|
|
173
136
|
// Listen for dynamic module events
|
|
174
137
|
const moduleHandler = async (payload: unknown) => {
|
|
@@ -335,25 +298,109 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
|
|
|
335
298
|
case "mcp_server_error":
|
|
336
299
|
return `Server "${String(p.name || "unknown")}" error: ${String(p.error || "unknown error")}`;
|
|
337
300
|
case "agent_end":
|
|
338
|
-
return "Agent finished responding";
|
|
301
|
+
return "Agent run finished responding";
|
|
302
|
+
case "agent_settled":
|
|
303
|
+
return "Agent is complete";
|
|
339
304
|
case "memory_consolidated":
|
|
340
305
|
return `Memory consolidated (${p.count || 0} items)`;
|
|
341
306
|
case "session_shutdown":
|
|
342
307
|
return "Session ending";
|
|
343
308
|
case "ask_user_prompt":
|
|
344
309
|
return buildAskUserPromptMessage(payload);
|
|
310
|
+
case "permission_request":
|
|
311
|
+
return buildPermissionPromptMessage(payload);
|
|
345
312
|
default:
|
|
346
313
|
return p.message ? String(p.message) : "Event occurred";
|
|
347
314
|
}
|
|
348
315
|
}
|
|
349
316
|
|
|
350
|
-
/**
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
317
|
+
/** Register an agent lifecycle notification with session name and recap support. */
|
|
318
|
+
function registerAgentNotification(
|
|
319
|
+
pi: ExtensionAPI,
|
|
320
|
+
eventKey: "agent_end" | "agent_settled",
|
|
321
|
+
config: NotifyConfig,
|
|
322
|
+
cwd: string
|
|
323
|
+
): void {
|
|
324
|
+
const eventConfig = config.events[eventKey];
|
|
325
|
+
if (!eventConfig?.enabled) return;
|
|
326
|
+
|
|
327
|
+
const handler = (payload: unknown) => {
|
|
328
|
+
// Fire-and-forget: build message and dispatch in background,
|
|
329
|
+
// don't block agent lifecycle hooks from completing.
|
|
330
|
+
const sessionName = pi.getSessionName?.();
|
|
331
|
+
const title = `Pi — ${BUILTIN_EVENTS[eventKey].label}`;
|
|
332
|
+
|
|
333
|
+
if (config.recap.enabled) {
|
|
334
|
+
// Recap mode: summarize asynchronously, then dispatch.
|
|
335
|
+
// agent_settled does not currently include a messages payload, so fall
|
|
336
|
+
// back to the latest assistant message in the session.
|
|
337
|
+
const lastText = extractLastAssistantText(payload) ?? extractLastAssistantTextFromSession();
|
|
338
|
+
if (lastText && sessionCtx?.modelRegistry) {
|
|
339
|
+
const provider = extractProvider(config.recap.model);
|
|
340
|
+
const modelId = extractModelId(config.recap.model);
|
|
341
|
+
const model = sessionCtx.modelRegistry.find(provider, modelId);
|
|
342
|
+
if (model) {
|
|
343
|
+
sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
|
|
344
|
+
.then((apiKeyResult) => {
|
|
345
|
+
const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
|
|
346
|
+
if (apiKey) {
|
|
347
|
+
return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
|
|
348
|
+
.then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
|
|
349
|
+
}
|
|
350
|
+
return buildAgentLifecycleMessage(eventKey, sessionName);
|
|
351
|
+
})
|
|
352
|
+
.catch(() => buildAgentLifecycleMessage(eventKey, sessionName))
|
|
353
|
+
.then((message) =>
|
|
354
|
+
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd)
|
|
355
|
+
)
|
|
356
|
+
.catch(() => {
|
|
357
|
+
// Silently ignore — background agent notification failure is non-blocking.
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// No recap or recap unavailable: dispatch immediately in background.
|
|
365
|
+
const message = buildAgentLifecycleMessage(eventKey, sessionName);
|
|
366
|
+
dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
|
|
367
|
+
() => {
|
|
368
|
+
// Silently ignore — background agent notification failure is non-blocking.
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
(pi as any).on(eventKey, handler);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Whether an event key is an agent lifecycle notification with custom handling. */
|
|
377
|
+
function isAgentNotificationEvent(eventKey: string): eventKey is "agent_end" | "agent_settled" {
|
|
378
|
+
return eventKey === "agent_end" || eventKey === "agent_settled";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Build agent lifecycle message using session name. */
|
|
382
|
+
function buildAgentLifecycleMessage(
|
|
383
|
+
eventKey: "agent_end" | "agent_settled",
|
|
384
|
+
sessionName: string | undefined
|
|
385
|
+
): string {
|
|
386
|
+
const status = eventKey === "agent_end" ? "Agent run is complete" : "Agent is complete";
|
|
387
|
+
if (sessionName) return `${sessionName} - ${status}`;
|
|
388
|
+
return status;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Extract text from the latest assistant message in the current session. */
|
|
392
|
+
function extractLastAssistantTextFromSession(): string | null {
|
|
393
|
+
const entries = sessionCtx?.sessionManager.getEntries() ?? [];
|
|
394
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
395
|
+
const entry = entries[i];
|
|
396
|
+
if (entry?.type !== "message") continue;
|
|
397
|
+
const text = extractAssistantText(entry.message);
|
|
398
|
+
if (text) return text;
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
354
401
|
}
|
|
355
402
|
|
|
356
|
-
/** Extract text from the last assistant message in
|
|
403
|
+
/** Extract text from the last assistant message in an agent lifecycle payload. */
|
|
357
404
|
function extractLastAssistantText(payload: unknown): string | null {
|
|
358
405
|
const p = payload as { messages?: Array<{ role?: string; content?: unknown }> };
|
|
359
406
|
if (!p?.messages || !Array.isArray(p.messages)) return null;
|
|
@@ -363,21 +410,31 @@ function extractLastAssistantText(payload: unknown): string | null {
|
|
|
363
410
|
const msg = p.messages[i];
|
|
364
411
|
if (msg?.role !== "assistant") continue;
|
|
365
412
|
|
|
366
|
-
const
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
413
|
+
const text = extractAssistantText(msg);
|
|
414
|
+
if (text) return text;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Extract text from an assistant message-like object. */
|
|
421
|
+
function extractAssistantText(message: { role?: string; content?: unknown }): string | null {
|
|
422
|
+
if (message.role !== "assistant") return null;
|
|
423
|
+
|
|
424
|
+
const content = message.content;
|
|
425
|
+
if (typeof content === "string") return content;
|
|
426
|
+
if (Array.isArray(content)) {
|
|
427
|
+
// Extract text blocks from content array.
|
|
428
|
+
const textParts: string[] = [];
|
|
429
|
+
for (const block of content) {
|
|
430
|
+
if (typeof block === "object" && block !== null) {
|
|
431
|
+
const b = block as { type?: string; text?: string };
|
|
432
|
+
if (b.type === "text" && typeof b.text === "string") {
|
|
433
|
+
textParts.push(b.text);
|
|
377
434
|
}
|
|
378
435
|
}
|
|
379
|
-
if (textParts.length > 0) return textParts.join("\n");
|
|
380
436
|
}
|
|
437
|
+
if (textParts.length > 0) return textParts.join("\n");
|
|
381
438
|
}
|
|
382
439
|
|
|
383
440
|
return null;
|
|
@@ -394,11 +451,3 @@ function extractModelId(modelRef: string): string {
|
|
|
394
451
|
const slashIdx = modelRef.indexOf("/");
|
|
395
452
|
return slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
|
|
396
453
|
}
|
|
397
|
-
|
|
398
|
-
/** Resolve API key for a provider from environment variables */
|
|
399
|
-
function resolveApiKey(modelRef: string): string | undefined {
|
|
400
|
-
const provider = extractProvider(modelRef);
|
|
401
|
-
// Try standard env var patterns
|
|
402
|
-
const envKey = `${provider.toUpperCase().replace(/-/g, "_")}_API_KEY`;
|
|
403
|
-
return process.env[envKey];
|
|
404
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/notify",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"notifications"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"test": "tsc --noEmit &&
|
|
23
|
+
"test": "tsc --noEmit && npx tsx --test src/__tests__/*.test.ts",
|
|
24
24
|
"typecheck": "tsc --noEmit"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@pi-unipi/core": "
|
|
45
|
+
"@pi-unipi/core": "2.2.0",
|
|
46
46
|
"node-notifier": "^10.0.1"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Internal helper: build notification message from
|
|
3
|
+
* permission prompt event payloads.
|
|
4
|
+
*
|
|
5
|
+
* Handles the `permissions:ui_prompt` broadcast emitted by
|
|
6
|
+
* `@gotgenes/pi-permission-system` immediately before a human-facing
|
|
7
|
+
* permission prompt is shown.
|
|
8
|
+
*
|
|
9
|
+
* @internal — not part of the public API. Shared by the event listener and tests.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PermissionPromptEventPayload {
|
|
13
|
+
/** Correlation id for the permission request */
|
|
14
|
+
requestId?: string;
|
|
15
|
+
/** Originating subsystem of the request */
|
|
16
|
+
source?: string;
|
|
17
|
+
/** Surface being requested — e.g. "bash", "mcp", "read", "edit" */
|
|
18
|
+
surface?: string | null;
|
|
19
|
+
/** Command / path / tool / skill being requested */
|
|
20
|
+
value?: string | null;
|
|
21
|
+
/** Current or requesting agent, when available */
|
|
22
|
+
agentName?: string | null;
|
|
23
|
+
/** Human-readable permission prompt text */
|
|
24
|
+
message?: string;
|
|
25
|
+
/** Present for forwarded subagent prompts */
|
|
26
|
+
forwarding?: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return value !== null && typeof value === "object";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Return a trimmed non-empty string, or `undefined` for anything else. */
|
|
34
|
+
function cleanString(value: unknown): string | undefined {
|
|
35
|
+
if (typeof value !== "string") return undefined;
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `forwarding` is documented as "present for forwarded subagent prompts".
|
|
42
|
+
* Treat an explicitly falsy value (false / null / "" / 0) as not forwarded so
|
|
43
|
+
* that publishers which always include the key are handled correctly.
|
|
44
|
+
*/
|
|
45
|
+
function isForwarded(value: unknown): boolean {
|
|
46
|
+
if (value === undefined || value === null) return false;
|
|
47
|
+
if (typeof value === "boolean") return value;
|
|
48
|
+
if (typeof value === "string") return value.trim().length > 0;
|
|
49
|
+
if (typeof value === "number") return value !== 0;
|
|
50
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
51
|
+
if (isRecord(value)) return Object.keys(value).length > 0;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the "what was requested" clause from `agentName` / `surface` / `value`.
|
|
57
|
+
* Returns `undefined` when there is nothing meaningful to say.
|
|
58
|
+
*/
|
|
59
|
+
function buildRequestClause(
|
|
60
|
+
agentName: string | undefined,
|
|
61
|
+
surface: string | undefined,
|
|
62
|
+
value: string | undefined,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
if (!surface && !value) return undefined;
|
|
65
|
+
|
|
66
|
+
const who = agentName ?? "Agent";
|
|
67
|
+
|
|
68
|
+
if (surface && value) return `${who} requested ${surface} '${value}'.`;
|
|
69
|
+
if (surface) return `${who} requested ${surface} access.`;
|
|
70
|
+
return `${who} requested '${value}'.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Build a human-readable notification message from a permission prompt payload. */
|
|
74
|
+
export function buildPermissionPromptMessage(payload: unknown): string {
|
|
75
|
+
const p = isRecord(payload) ? payload : {};
|
|
76
|
+
|
|
77
|
+
const agentName = cleanString(p.agentName);
|
|
78
|
+
const surface = cleanString(p.surface);
|
|
79
|
+
const value = cleanString(p.value);
|
|
80
|
+
const message = cleanString(p.message);
|
|
81
|
+
|
|
82
|
+
const parts: string[] = [];
|
|
83
|
+
|
|
84
|
+
const requestClause = buildRequestClause(agentName, surface, value);
|
|
85
|
+
if (requestClause) parts.push(requestClause);
|
|
86
|
+
|
|
87
|
+
// Only append the prompt text when it adds information beyond the clause.
|
|
88
|
+
if (message && message !== requestClause) parts.push(message);
|
|
89
|
+
|
|
90
|
+
if (parts.length === 0) parts.push("Pi is waiting for a permission decision.");
|
|
91
|
+
|
|
92
|
+
if (isForwarded(p.forwarding)) parts.push("(forwarded)");
|
|
93
|
+
|
|
94
|
+
return parts.join(" ");
|
|
95
|
+
}
|
|
@@ -24,9 +24,11 @@ export const DEFAULT_CONFIG: NotifyConfig = {
|
|
|
24
24
|
ralph_loop_end: { enabled: true, platforms: [] },
|
|
25
25
|
mcp_server_error: { enabled: true, platforms: [] },
|
|
26
26
|
agent_end: { enabled: false, platforms: [] },
|
|
27
|
+
agent_settled: { enabled: false, platforms: [] },
|
|
27
28
|
memory_consolidated: { enabled: false, platforms: [] },
|
|
28
29
|
session_shutdown: { enabled: false, platforms: [] },
|
|
29
30
|
ask_user_prompt: { enabled: false, platforms: [] },
|
|
31
|
+
permission_request: { enabled: false, platforms: [] },
|
|
30
32
|
},
|
|
31
33
|
native: {
|
|
32
34
|
enabled: true,
|
|
@@ -34,8 +34,11 @@ Help users configure the `@pi-unipi/notify` notification system.
|
|
|
34
34
|
"ralph_loop_end": { "enabled": true, "platforms": [] },
|
|
35
35
|
"mcp_server_error": { "enabled": true, "platforms": [] },
|
|
36
36
|
"agent_end": { "enabled": false, "platforms": [] },
|
|
37
|
+
"agent_settled": { "enabled": false, "platforms": [] },
|
|
37
38
|
"memory_consolidated": { "enabled": false, "platforms": [] },
|
|
38
|
-
"session_shutdown": { "enabled": false, "platforms": [] }
|
|
39
|
+
"session_shutdown": { "enabled": false, "platforms": [] },
|
|
40
|
+
"ask_user_prompt": { "enabled": false, "platforms": [] },
|
|
41
|
+
"permission_request": { "enabled": false, "platforms": [] }
|
|
39
42
|
},
|
|
40
43
|
"native": {
|
|
41
44
|
"enabled": true,
|
|
@@ -150,12 +153,38 @@ ntfy uses dedicated `ntfy.json` files at both global and project scope, with ful
|
|
|
150
153
|
| `workflow_end` | On | Workflow command completes |
|
|
151
154
|
| `ralph_loop_end` | On | Ralph loop completes |
|
|
152
155
|
| `mcp_server_error` | On | MCP server error |
|
|
153
|
-
| `agent_end` | Off |
|
|
156
|
+
| `agent_end` | Off | Low-level agent run ends (may fire again on retries) |
|
|
157
|
+
| `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
|
|
154
158
|
| `memory_consolidated` | Off | Memory auto-saved |
|
|
155
159
|
| `session_shutdown` | Off | Session ends |
|
|
160
|
+
| `ask_user_prompt` | Off | Agent asked a question and is waiting for an answer |
|
|
161
|
+
| `permission_request` | Off | A permission prompt is about to be shown |
|
|
156
162
|
|
|
157
163
|
Each event can override `platforms` — empty array means use `defaultPlatforms`.
|
|
158
164
|
|
|
165
|
+
### `permission_request`
|
|
166
|
+
|
|
167
|
+
Fires on the `permissions:ui_prompt` broadcast from
|
|
168
|
+
[`@gotgenes/pi-permission-system`](https://www.npmjs.com/package/@gotgenes/pi-permission-system),
|
|
169
|
+
emitted immediately before the user-facing permission UI is invoked. Policy
|
|
170
|
+
auto-allow, policy deny, session approvals, and infrastructure auto-allowed
|
|
171
|
+
requests do **not** emit it, so this event does not produce notification spam.
|
|
172
|
+
Forwarded subagent prompts are handled too — the parent UI session emits the
|
|
173
|
+
event right before showing the forwarded dialog, and the notification is
|
|
174
|
+
suffixed with `(forwarded)`.
|
|
175
|
+
|
|
176
|
+
The notification is formatted defensively from the payload's `agentName`,
|
|
177
|
+
`surface`, `value` and `message` fields:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
Pi — Permission Request
|
|
181
|
+
Current agent requested bash 'npm test'. Allow this command?
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Enable it when you run Pi in a background pane and don't want to miss a
|
|
185
|
+
permission prompt. If the permission system is not installed the event simply
|
|
186
|
+
never fires.
|
|
187
|
+
|
|
159
188
|
## Agent workflow
|
|
160
189
|
|
|
161
190
|
### Reading current config
|
|
@@ -27,6 +27,7 @@ function readSource(relativePath: string): string {
|
|
|
27
27
|
|
|
28
28
|
const LIFECYCLE_EVENTS = new Set([
|
|
29
29
|
"agent_end",
|
|
30
|
+
"agent_settled",
|
|
30
31
|
"session_shutdown",
|
|
31
32
|
]);
|
|
32
33
|
|
|
@@ -47,7 +48,7 @@ describe("notify — event bus registration", () => {
|
|
|
47
48
|
assert.deepStrictEqual(
|
|
48
49
|
parsed.sort(),
|
|
49
50
|
[...LIFECYCLE_EVENTS].sort(),
|
|
50
|
-
"LIFECYCLE_EVENTS should contain exactly agent_end and session_shutdown",
|
|
51
|
+
"LIFECYCLE_EVENTS should contain exactly agent_end, agent_settled, and session_shutdown",
|
|
51
52
|
);
|
|
52
53
|
});
|
|
53
54
|
|
|
@@ -67,7 +68,7 @@ describe("notify — event bus registration", () => {
|
|
|
67
68
|
);
|
|
68
69
|
});
|
|
69
70
|
|
|
70
|
-
it("lifecycle events (agent_end, session_shutdown) still use pi.on()", () => {
|
|
71
|
+
it("lifecycle events (agent_end, agent_settled, session_shutdown) still use pi.on()", () => {
|
|
71
72
|
const src = readSource("packages/notify/events.ts");
|
|
72
73
|
|
|
73
74
|
assert.ok(
|