@pi-unipi/unipi 2.1.2 → 2.1.3

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.
Files changed (41) hide show
  1. package/package.json +22 -22
  2. package/packages/ask-user/package.json +3 -3
  3. package/packages/autocomplete/package.json +1 -1
  4. package/packages/btw/package.json +2 -2
  5. package/packages/cocoindex/package.json +2 -2
  6. package/packages/compactor/package.json +4 -4
  7. package/packages/core/package.json +1 -1
  8. package/packages/footer/package.json +3 -3
  9. package/packages/info-screen/package.json +2 -2
  10. package/packages/input-shortcuts/package.json +3 -3
  11. package/packages/kanboard/package.json +2 -2
  12. package/packages/mcp/package.json +2 -2
  13. package/packages/memory/package.json +3 -3
  14. package/packages/milestone/package.json +2 -2
  15. package/packages/notify/README.md +2 -1
  16. package/packages/notify/events.ts +119 -81
  17. package/packages/notify/package.json +2 -2
  18. package/packages/notify/settings.ts +1 -0
  19. package/packages/notify/skills/configure-notify/SKILL.md +3 -1
  20. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  21. package/packages/ralph/package.json +3 -3
  22. package/packages/subagents/package.json +5 -5
  23. package/packages/updater/package.json +3 -3
  24. package/packages/utility/package.json +2 -2
  25. package/packages/web-api/package.json +2 -2
  26. package/packages/workflow/package.json +2 -2
  27. package/packages/info-screen/tui/info-overlay.ts +0 -504
  28. package/packages/kanboard/tui/kanboard-overlay.ts +0 -300
  29. package/packages/memory/tui/settings-tui.ts +0 -281
  30. package/packages/notify/platforms/focus-win.ts +0 -123
  31. package/packages/notify/platforms/focus.ts +0 -33
  32. package/packages/notify/platforms/gotify.ts +0 -36
  33. package/packages/notify/platforms/native.ts +0 -79
  34. package/packages/notify/platforms/node-notifier.d.ts +0 -20
  35. package/packages/notify/platforms/ntfy.ts +0 -48
  36. package/packages/notify/platforms/telegram.ts +0 -77
  37. package/packages/notify/tui/gotify-setup.ts +0 -528
  38. package/packages/notify/tui/ntfy-setup.ts +0 -652
  39. package/packages/notify/tui/recap-model-selector.ts +0 -301
  40. package/packages/notify/tui/settings-overlay.ts +0 -352
  41. package/packages/notify/tui/telegram-setup.ts +0 -301
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "typecheck": "npx tsc --noEmit --skipLibCheck",
27
- "test": "node --test tests/*.test.js && npm test --workspaces --if-present",
27
+ "test": "npx tsx --test tests/*.test.js && npm test --workspaces --if-present",
28
28
  "publish:all": "npm publish --workspaces --access public"
29
29
  },
30
30
  "files": [
@@ -65,26 +65,26 @@
65
65
  "typebox": "^1.1.38"
66
66
  },
67
67
  "dependencies": {
68
- "@pi-unipi/ask-user": "2.1.2",
69
- "@pi-unipi/btw": "2.1.2",
70
- "@pi-unipi/compactor": "2.1.2",
71
- "@pi-unipi/notify": "2.1.2",
72
- "@pi-unipi/command-enchantment": "2.1.2",
73
- "@pi-unipi/core": "2.1.2",
74
- "@pi-unipi/info-screen": "2.1.2",
75
- "@pi-unipi/mcp": "2.1.2",
76
- "@pi-unipi/memory": "2.1.2",
77
- "@pi-unipi/ralph": "2.1.2",
78
- "@pi-unipi/subagents": "2.1.2",
79
- "@pi-unipi/utility": "2.1.2",
80
- "@pi-unipi/milestone": "2.1.2",
81
- "@pi-unipi/kanboard": "2.1.2",
82
- "@pi-unipi/web-api": "2.1.2",
83
- "@pi-unipi/workflow": "2.1.2",
84
- "@pi-unipi/footer": "2.1.2",
85
- "@pi-unipi/updater": "2.1.2",
86
- "@pi-unipi/input-shortcuts": "2.1.2",
87
- "@pi-unipi/cocoindex": "2.1.2"
68
+ "@pi-unipi/ask-user": "2.1.3",
69
+ "@pi-unipi/btw": "2.1.3",
70
+ "@pi-unipi/compactor": "2.1.3",
71
+ "@pi-unipi/notify": "2.1.3",
72
+ "@pi-unipi/command-enchantment": "2.1.3",
73
+ "@pi-unipi/core": "2.1.3",
74
+ "@pi-unipi/info-screen": "2.1.3",
75
+ "@pi-unipi/mcp": "2.1.3",
76
+ "@pi-unipi/memory": "2.1.3",
77
+ "@pi-unipi/ralph": "2.1.3",
78
+ "@pi-unipi/subagents": "2.1.3",
79
+ "@pi-unipi/utility": "2.1.3",
80
+ "@pi-unipi/milestone": "2.1.3",
81
+ "@pi-unipi/kanboard": "2.1.3",
82
+ "@pi-unipi/web-api": "2.1.3",
83
+ "@pi-unipi/workflow": "2.1.3",
84
+ "@pi-unipi/footer": "2.1.3",
85
+ "@pi-unipi/updater": "2.1.3",
86
+ "@pi-unipi/input-shortcuts": "2.1.3",
87
+ "@pi-unipi/cocoindex": "2.1.3"
88
88
  },
89
89
  "devDependencies": {
90
90
  "@earendil-works/pi-agent-core": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -34,13 +34,13 @@
34
34
  "README.md"
35
35
  ],
36
36
  "scripts": {
37
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
37
+ "test": "npx tsx --test tests/**/*.test.ts"
38
38
  },
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.1.2"
43
+ "@pi-unipi/core": "2.1.3"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.80.0"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "2.1.2"
40
+ "@pi-unipi/core": "2.1.3"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/cocoindex",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "CocoIndex integration for Pi — AST-aware content indexing, semantic vector search, and incremental pipeline management",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -25,7 +25,7 @@
25
25
  "typebox": "^1.1.38"
26
26
  },
27
27
  "dependencies": {
28
- "@pi-unipi/core": "2.1.2"
28
+ "@pi-unipi/core": "2.1.3"
29
29
  },
30
30
  "optionalDependencies": {
31
31
  "@lancedb/lancedb": "^0.21.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -22,7 +22,7 @@
22
22
  "fts5"
23
23
  ],
24
24
  "scripts": {
25
- "test": "bun test tests"
25
+ "test": "npx bun test tests"
26
26
  },
27
27
  "files": [
28
28
  "index.ts",
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.1.2",
38
- "@pi-unipi/info-screen": "2.1.2",
37
+ "@pi-unipi/core": "2.1.3",
38
+ "@pi-unipi/info-screen": "2.1.3",
39
39
  "@earendil-works/pi-agent-core": "^0.80.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -26,13 +26,13 @@
26
26
  "README.md"
27
27
  ],
28
28
  "scripts": {
29
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
29
+ "test": "npx tsx --test tests/**/*.test.ts"
30
30
  },
31
31
  "publishConfig": {
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.1.2"
35
+ "@pi-unipi/core": "2.1.3"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/info-screen",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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.1.2"
36
+ "@pi-unipi/core": "2.1.3"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/input-shortcuts",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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",
@@ -27,13 +27,13 @@
27
27
  "README.md"
28
28
  ],
29
29
  "scripts": {
30
- "test": "node --experimental-strip-types --test tests/**/*.test.ts"
30
+ "test": "npx tsx --test tests/**/*.test.ts"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
34
  },
35
35
  "dependencies": {
36
- "@pi-unipi/core": "2.1.2"
36
+ "@pi-unipi/core": "2.1.3"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/kanboard",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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.1.2"
42
+ "@pi-unipi/core": "2.1.3"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/mcp",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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",
@@ -27,7 +27,7 @@
27
27
  "README.md"
28
28
  ],
29
29
  "dependencies": {
30
- "@pi-unipi/core": "2.1.2"
30
+ "@pi-unipi/core": "2.1.3"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/memory",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
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.1.2",
47
- "@pi-unipi/info-screen": "2.1.2"
46
+ "@pi-unipi/core": "2.1.3",
47
+ "@pi-unipi/info-screen": "2.1.3"
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.1.2",
3
+ "version": "2.1.3",
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.1.2"
32
+ "@pi-unipi/core": "2.1.3"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "^0.80.0",
@@ -24,7 +24,8 @@ 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 | Agent finishes responding |
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 |
30
31
 
@@ -50,7 +50,8 @@ export const BUILTIN_EVENTS: Record<
50
50
  string,
51
51
  { hook: string; label: string }
52
52
  > = {
53
- agent_end: { hook: "agent_end", label: "Agent Complete" },
53
+ agent_end: { hook: "agent_end", label: "Agent Run Complete" },
54
+ agent_settled: { hook: "agent_settled", label: "Agent Complete" },
54
55
  workflow_end: { hook: UNIPI_EVENTS.WORKFLOW_END, label: "Workflow Done" },
55
56
  ralph_loop_end: { hook: UNIPI_EVENTS.RALPH_LOOP_END, label: "Ralph Complete" },
56
57
  mcp_server_error: { hook: UNIPI_EVENTS.MCP_SERVER_ERROR, label: "MCP Error" },
@@ -63,7 +64,7 @@ export const BUILTIN_EVENTS: Record<
63
64
  * Pi lifecycle event types (dispatched by ExtensionRunner).
64
65
  * These must use pi.on() — not pi.events.on() — to receive events.
65
66
  */
66
- const LIFECYCLE_EVENTS = new Set(["agent_end", "session_shutdown"]);
67
+ const LIFECYCLE_EVENTS = new Set(["agent_end", "agent_settled", "session_shutdown"]);
67
68
 
68
69
  /**
69
70
  * Register event listeners for all enabled notification events.
@@ -77,9 +78,9 @@ export function registerEventListeners(
77
78
  // Remove all previously registered EventBus listeners to prevent accumulation
78
79
  // across reloads (EventBus persists but module instances are replaced).
79
80
  unregisterAll();
80
- // Register built-in events (except agent_end which has custom logic)
81
+ // Register built-in events (except agent lifecycle notifications which have custom logic)
81
82
  for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
82
- if (eventKey === "agent_end") continue; // handled separately below
83
+ if (isAgentNotificationEvent(eventKey)) continue; // handled separately below
83
84
 
84
85
  const eventConfig = config.events[eventKey];
85
86
  if (!eventConfig?.enabled) continue;
@@ -95,8 +96,8 @@ export function registerEventListeners(
95
96
  );
96
97
  };
97
98
 
98
- // pi lifecycle events (agent_end, session_shutdown) are dispatched via
99
- // ExtensionRunner — must use pi.on(). These are stored in
99
+ // Pi lifecycle events are dispatched via ExtensionRunner — must use
100
+ // pi.on(). These are stored in
100
101
  // extension.handlers and automatically replaced on reload, so they
101
102
  // do NOT accumulate like EventBus listeners.
102
103
  if (LIFECYCLE_EVENTS.has(eventKey)) {
@@ -120,55 +121,8 @@ export function registerEventListeners(
120
121
  }));
121
122
  }
122
123
 
123
- // agent_end custom handler with session name and recap support
124
- const agentEndConfig = config.events["agent_end"];
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
- }
124
+ registerAgentNotification(pi, "agent_end", config, cwd);
125
+ registerAgentNotification(pi, "agent_settled", config, cwd);
172
126
 
173
127
  // Listen for dynamic module events
174
128
  const moduleHandler = async (payload: unknown) => {
@@ -335,7 +289,9 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
335
289
  case "mcp_server_error":
336
290
  return `Server "${String(p.name || "unknown")}" error: ${String(p.error || "unknown error")}`;
337
291
  case "agent_end":
338
- return "Agent finished responding";
292
+ return "Agent run finished responding";
293
+ case "agent_settled":
294
+ return "Agent is complete";
339
295
  case "memory_consolidated":
340
296
  return `Memory consolidated (${p.count || 0} items)`;
341
297
  case "session_shutdown":
@@ -347,13 +303,93 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
347
303
  }
348
304
  }
349
305
 
350
- /** Build agent_end message using session name */
351
- function buildAgentEndMessage(sessionName: string | undefined): string {
352
- if (sessionName) return `${sessionName} - Agent is complete`;
353
- return "Agent is complete";
306
+ /** Register an agent lifecycle notification with session name and recap support. */
307
+ function registerAgentNotification(
308
+ pi: ExtensionAPI,
309
+ eventKey: "agent_end" | "agent_settled",
310
+ config: NotifyConfig,
311
+ cwd: string
312
+ ): void {
313
+ const eventConfig = config.events[eventKey];
314
+ if (!eventConfig?.enabled) return;
315
+
316
+ const handler = (payload: unknown) => {
317
+ // Fire-and-forget: build message and dispatch in background,
318
+ // don't block agent lifecycle hooks from completing.
319
+ const sessionName = pi.getSessionName?.();
320
+ const title = `Pi — ${BUILTIN_EVENTS[eventKey].label}`;
321
+
322
+ if (config.recap.enabled) {
323
+ // Recap mode: summarize asynchronously, then dispatch.
324
+ // agent_settled does not currently include a messages payload, so fall
325
+ // back to the latest assistant message in the session.
326
+ const lastText = extractLastAssistantText(payload) ?? extractLastAssistantTextFromSession();
327
+ if (lastText && sessionCtx?.modelRegistry) {
328
+ const provider = extractProvider(config.recap.model);
329
+ const modelId = extractModelId(config.recap.model);
330
+ const model = sessionCtx.modelRegistry.find(provider, modelId);
331
+ if (model) {
332
+ sessionCtx.modelRegistry.getApiKeyAndHeaders(model)
333
+ .then((apiKeyResult) => {
334
+ const apiKey = apiKeyResult.ok ? (apiKeyResult as { apiKey?: string }).apiKey : undefined;
335
+ if (apiKey) {
336
+ return summarizeLastMessage(lastText, apiKey, model.baseUrl, model.api, modelId)
337
+ .then((recap) => sessionName ? `${sessionName}: ${recap}` : recap);
338
+ }
339
+ return buildAgentLifecycleMessage(eventKey, sessionName);
340
+ })
341
+ .catch(() => buildAgentLifecycleMessage(eventKey, sessionName))
342
+ .then((message) =>
343
+ dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd)
344
+ )
345
+ .catch(() => {
346
+ // Silently ignore — background agent notification failure is non-blocking.
347
+ });
348
+ return;
349
+ }
350
+ }
351
+ }
352
+
353
+ // No recap or recap unavailable: dispatch immediately in background.
354
+ const message = buildAgentLifecycleMessage(eventKey, sessionName);
355
+ dispatchNotification(pi, title, message, eventConfig.platforms, eventKey, config, cwd).catch(
356
+ () => {
357
+ // Silently ignore — background agent notification failure is non-blocking.
358
+ }
359
+ );
360
+ };
361
+
362
+ (pi as any).on(eventKey, handler);
363
+ }
364
+
365
+ /** Whether an event key is an agent lifecycle notification with custom handling. */
366
+ function isAgentNotificationEvent(eventKey: string): eventKey is "agent_end" | "agent_settled" {
367
+ return eventKey === "agent_end" || eventKey === "agent_settled";
368
+ }
369
+
370
+ /** Build agent lifecycle message using session name. */
371
+ function buildAgentLifecycleMessage(
372
+ eventKey: "agent_end" | "agent_settled",
373
+ sessionName: string | undefined
374
+ ): string {
375
+ const status = eventKey === "agent_end" ? "Agent run is complete" : "Agent is complete";
376
+ if (sessionName) return `${sessionName} - ${status}`;
377
+ return status;
378
+ }
379
+
380
+ /** Extract text from the latest assistant message in the current session. */
381
+ function extractLastAssistantTextFromSession(): string | null {
382
+ const entries = sessionCtx?.sessionManager.getEntries() ?? [];
383
+ for (let i = entries.length - 1; i >= 0; i--) {
384
+ const entry = entries[i];
385
+ if (entry?.type !== "message") continue;
386
+ const text = extractAssistantText(entry.message);
387
+ if (text) return text;
388
+ }
389
+ return null;
354
390
  }
355
391
 
356
- /** Extract text from the last assistant message in agent_end payload */
392
+ /** Extract text from the last assistant message in an agent lifecycle payload. */
357
393
  function extractLastAssistantText(payload: unknown): string | null {
358
394
  const p = payload as { messages?: Array<{ role?: string; content?: unknown }> };
359
395
  if (!p?.messages || !Array.isArray(p.messages)) return null;
@@ -363,21 +399,31 @@ function extractLastAssistantText(payload: unknown): string | null {
363
399
  const msg = p.messages[i];
364
400
  if (msg?.role !== "assistant") continue;
365
401
 
366
- const content = msg.content;
367
- if (typeof content === "string") return content;
368
- if (Array.isArray(content)) {
369
- // Extract text blocks from content array
370
- const textParts: string[] = [];
371
- for (const block of content) {
372
- if (typeof block === "object" && block !== null) {
373
- const b = block as { type?: string; text?: string };
374
- if (b.type === "text" && typeof b.text === "string") {
375
- textParts.push(b.text);
376
- }
402
+ const text = extractAssistantText(msg);
403
+ if (text) return text;
404
+ }
405
+
406
+ return null;
407
+ }
408
+
409
+ /** Extract text from an assistant message-like object. */
410
+ function extractAssistantText(message: { role?: string; content?: unknown }): string | null {
411
+ if (message.role !== "assistant") return null;
412
+
413
+ const content = message.content;
414
+ if (typeof content === "string") return content;
415
+ if (Array.isArray(content)) {
416
+ // Extract text blocks from content array.
417
+ const textParts: string[] = [];
418
+ for (const block of content) {
419
+ if (typeof block === "object" && block !== null) {
420
+ const b = block as { type?: string; text?: string };
421
+ if (b.type === "text" && typeof b.text === "string") {
422
+ textParts.push(b.text);
377
423
  }
378
424
  }
379
- if (textParts.length > 0) return textParts.join("\n");
380
425
  }
426
+ if (textParts.length > 0) return textParts.join("\n");
381
427
  }
382
428
 
383
429
  return null;
@@ -394,11 +440,3 @@ function extractModelId(modelRef: string): string {
394
440
  const slashIdx = modelRef.indexOf("/");
395
441
  return slashIdx > 0 ? modelRef.slice(slashIdx + 1) : modelRef;
396
442
  }
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.1.2",
3
+ "version": "2.1.3",
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 && node --experimental-strip-types --test src/__tests__/*.test.ts",
23
+ "test": "tsc --noEmit && npx tsx --test src/__tests__/*.test.ts",
24
24
  "typecheck": "tsc --noEmit"
25
25
  },
26
26
  "files": [
@@ -24,6 +24,7 @@ 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: [] },
@@ -34,6 +34,7 @@ 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
39
  "session_shutdown": { "enabled": false, "platforms": [] }
39
40
  },
@@ -150,7 +151,8 @@ ntfy uses dedicated `ntfy.json` files at both global and project scope, with ful
150
151
  | `workflow_end` | On | Workflow command completes |
151
152
  | `ralph_loop_end` | On | Ralph loop completes |
152
153
  | `mcp_server_error` | On | MCP server error |
153
- | `agent_end` | Off | Agent finishes responding |
154
+ | `agent_end` | Off | Low-level agent run ends (may fire again on retries) |
155
+ | `agent_settled` | Off | Agent fully settles after retries, compaction, and queued continuations |
154
156
  | `memory_consolidated` | Off | Memory auto-saved |
155
157
  | `session_shutdown` | Off | Session ends |
156
158
 
@@ -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(