pi-all-notification 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,19 +16,15 @@ pi 多渠道通知扩展 — macOS 桌面通知 / Bark 推送 / 自定义 Webhoo
16
16
  pi install npm:pi-all-notification
17
17
  ```
18
18
 
19
- 或通过 git:
20
-
21
- ```bash
22
- pi install git:github.com/RealAlexandreAI/pi-all-notification
23
- ```
24
-
25
19
  重启 pi(或 `/reload`)。
26
20
 
27
21
  ## 使用
28
22
 
29
23
  ### Agent 自动触发
30
24
 
31
- Agent 完成响应时自动通知(`agent_end` 事件)。可在设置面板 toggle 触发时机。
25
+ 一轮运行**最终结束**时自动通知(Pi 的 `agent_settled` 事件)。自然结束发 `✅ Complete`,被用户取消发 `⏹ Cancelled`,两者不会混为一谈。可在设置面板 toggle 触发时机。
26
+
27
+ > 用的是 `agent_settled` 而不是 `agent_end`:`agent_end` 之后还可能跟着重试、压缩恢复或排队的消息,在那里发通知既可能过早,也无法区分「完成」和「被取消」。
32
28
 
33
29
  ### Agent 手动调用
34
30
 
@@ -111,7 +107,7 @@ Webhook 支持 GET 和 POST,Body 模板支持变量替换:
111
107
 
112
108
  | 事件 | 默认 | 说明 |
113
109
  |------|------|------|
114
- | `agent_end` | ✅ | Agent 完成响应,等待输入 |
110
+ | `agent_end` | ✅ | 一轮运行结束(自然完成 `✅` 或被取消 `⏹`) |
115
111
  | `agent_start` | ⬜ | Agent 开始处理 |
116
112
  | `tool_error` | ⬜ | 工具执行出错 |
117
113
 
@@ -18,6 +18,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import { join, dirname } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  import { execSync } from "node:child_process";
21
+ import { describeTask, settleNotification } from "./settle.ts";
21
22
 
22
23
  // ─── Types ────────────────────────────────────────────────────
23
24
 
@@ -46,6 +47,10 @@ interface WebhookConfig extends ChannelConfig {
46
47
  }
47
48
 
48
49
  interface TriggerConfig {
50
+ /**
51
+ * Notify when a run settles: naturally (`✅ Complete`) or cancelled (`⏹ Cancelled`).
52
+ * Named `agent_end` for backward compatibility with existing config files.
53
+ */
49
54
  agent_end: boolean;
50
55
  agent_start: boolean;
51
56
  tool_error: boolean;
@@ -252,6 +257,14 @@ function settingLabel(emoji: string, name: string, enabled: boolean, extra?: str
252
257
 
253
258
  export default function (pi: ExtensionAPI) {
254
259
  let projectName = "";
260
+ /**
261
+ * The prompt that started the current run, for the completion notification.
262
+ *
263
+ * `agent_settled` carries no messages, so the text is kept from
264
+ * `before_agent_start` instead; `agent_end` used to read it off
265
+ * `event.messages`, which `agent_settled` does not have.
266
+ */
267
+ let lastTaskDescription: string | null = null;
255
268
 
256
269
  // ── notify_user agent tool ────────────────────────────
257
270
  pi.registerTool({
@@ -681,7 +694,7 @@ export default function (pi: ExtensionAPI) {
681
694
  while (true) {
682
695
  const t = cfg.triggers;
683
696
  const items = [
684
- { label: settingLabel("🏁", "agent_end (response complete)", t.agent_end), value: "agent_end" },
697
+ { label: settingLabel("🏁", "agent_end (run settled: complete or cancelled)", t.agent_end), value: "agent_end" },
685
698
  { label: settingLabel("🚀", "agent_start (processing begins)", t.agent_start), value: "agent_start" },
686
699
  { label: settingLabel("❌", "tool_error (tool execution error)", t.tool_error), value: "tool_error" },
687
700
  { label: "← Back", value: "back" },
@@ -747,6 +760,12 @@ export default function (pi: ExtensionAPI) {
747
760
  projectName = ctx.cwd.split(/[/\\]/).pop() || "";
748
761
  });
749
762
 
763
+ // Capture the prompt for the completion notification: `agent_settled` carries
764
+ // no messages, and this is the last point the prompt text is available.
765
+ pi.on("before_agent_start", (event, _ctx) => {
766
+ lastTaskDescription = describeTask((event as { prompt?: unknown }).prompt);
767
+ });
768
+
750
769
  pi.on("agent_start", (_event, ctx) => {
751
770
  projectName = ctx.cwd.split(/[/\\]/).pop() || "";
752
771
  const cfg = loadConfig();
@@ -762,28 +781,22 @@ export default function (pi: ExtensionAPI) {
762
781
  fireAll(cfg, vars);
763
782
  });
764
783
 
765
- pi.on("agent_end", (event, _ctx) => {
784
+ // `agent_settled` is Pi's final notification boundary and is the only event
785
+ // that says whether the run was cancelled. `agent_end` fires earlier and can
786
+ // still be followed by retries, recovery or queued work, so a notification
787
+ // sent there can be both premature and wrong about how the run finished.
788
+ pi.on("agent_settled", (event, _ctx) => {
766
789
  const cfg = loadConfig();
767
790
  if (!cfg.triggers.agent_end) return;
768
791
 
769
- let taskDesc = projectName;
770
- if (event.messages?.length) {
771
- for (let i = event.messages.length - 1; i >= 0; i--) {
772
- const m = event.messages[i] as any;
773
- if (m.role === "user" && m.content) {
774
- const raw = typeof m.content === "string" ? m.content.trim() : "";
775
- const firstLine = raw.split(/\n/)[0] || "";
776
- taskDesc = firstLine.length > 40 ? firstLine.slice(0, 37) + "..." : firstLine;
777
- break;
778
- }
779
- }
780
- }
781
-
792
+ const decision = settleNotification(
793
+ event as { aborted?: unknown },
794
+ projectName,
795
+ lastTaskDescription,
796
+ );
782
797
  const vars: NotificationVars = {
783
- title: "pi ✅ Complete",
784
- message: taskDesc,
798
+ ...decision,
785
799
  project: projectName,
786
- emoji: "✅",
787
800
  timestamp: new Date().toISOString(),
788
801
  };
789
802
  fireAll(cfg, vars);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * What a settled run should notify about.
3
+ *
4
+ * Pi's `agent_settled` is the final notification boundary and the only event
5
+ * that reports whether the run was aborted. `agent_end` fires earlier and may
6
+ * still be followed by retries, recovery or queued work, so a notification sent
7
+ * there can be both premature and wrong about how the run finished. This module
8
+ * holds the decision alone, so it is testable without loading the extension.
9
+ */
10
+
11
+ export interface SettleNotification {
12
+ title: string;
13
+ message: string;
14
+ emoji: string;
15
+ }
16
+
17
+ /** A run's prompt, collapsed to one short line for a notification body. */
18
+ export function describeTask(prompt: unknown): string | null {
19
+ if (typeof prompt !== "string") return null;
20
+ const firstLine = prompt.trim().split(/\n/)[0] ?? "";
21
+ if (!firstLine) return null;
22
+ return firstLine.length > 40 ? `${firstLine.slice(0, 37)}...` : firstLine;
23
+ }
24
+
25
+ /**
26
+ * The notification for a settled run.
27
+ *
28
+ * A cancelled run says so instead of reporting success: `aborted` distinguishes
29
+ * a run the person stopped from one that finished on its own, and calling a
30
+ * cancellation "Complete" is the bug this guards.
31
+ */
32
+ export function settleNotification(
33
+ event: { aborted?: unknown },
34
+ projectName: string,
35
+ taskDescription: string | null,
36
+ ): SettleNotification {
37
+ if (event?.aborted === true) {
38
+ return {
39
+ title: "pi ⏹ Cancelled",
40
+ message: `Run cancelled — ${projectName}`,
41
+ emoji: "⏹",
42
+ };
43
+ }
44
+ return {
45
+ title: "pi ✅ Complete",
46
+ message: taskDescription ?? projectName,
47
+ emoji: "✅",
48
+ };
49
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-all-notification",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Multi-channel notification for pi: macOS desktop, Bark push, custom webhook",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,7 +19,13 @@
19
19
  "./extensions/index.ts"
20
20
  ]
21
21
  },
22
+ "scripts": {
23
+ "test": "node --import tsx --test tests/*.test.ts"
24
+ },
25
+ "devDependencies": {
26
+ "tsx": "^4.23.15"
27
+ },
22
28
  "peerDependencies": {
23
- "@earendil-works/pi-coding-agent": ">=0.1.0"
29
+ "@earendil-works/pi-coding-agent": "^1.1.0"
24
30
  }
25
31
  }
@@ -0,0 +1,49 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { describeTask, settleNotification } from "../extensions/settle.ts";
4
+
5
+ describe("describeTask", () => {
6
+ it("takes the first line of the prompt", () => {
7
+ assert.equal(describeTask("Fix the build\nand then tag it"), "Fix the build");
8
+ });
9
+
10
+ it("truncates a long first line", () => {
11
+ const long = "a".repeat(100);
12
+ const result = describeTask(long);
13
+ assert.equal(result, `${"a".repeat(37)}...`);
14
+ assert.equal(result?.length, 40);
15
+ });
16
+
17
+ it("returns null for empty or non-string prompts", () => {
18
+ assert.equal(describeTask(""), null);
19
+ assert.equal(describeTask(" \n "), null);
20
+ assert.equal(describeTask(undefined), null);
21
+ assert.equal(describeTask({ text: "hi" }), null);
22
+ });
23
+ });
24
+
25
+ describe("settleNotification", () => {
26
+ it("reports a natural finish as complete", () => {
27
+ const note = settleNotification({ aborted: false }, "pi-plugins", "Run the tests");
28
+ assert.equal(note.emoji, "✅");
29
+ assert.match(note.title, /Complete/);
30
+ assert.equal(note.message, "Run the tests");
31
+ });
32
+
33
+ it("reports a cancelled run as cancelled, not complete", () => {
34
+ const note = settleNotification({ aborted: true }, "pi-plugins", "Run the tests");
35
+ assert.equal(note.emoji, "⏹");
36
+ assert.match(note.title, /Cancelled/);
37
+ assert.match(note.message, /cancelled/i);
38
+ });
39
+
40
+ it("falls back to the project name when no prompt was captured", () => {
41
+ const note = settleNotification({ aborted: false }, "pi-plugins", null);
42
+ assert.equal(note.message, "pi-plugins");
43
+ });
44
+
45
+ it("treats an absent aborted flag as a natural finish", () => {
46
+ const note = settleNotification({}, "pi-plugins", "task");
47
+ assert.equal(note.emoji, "✅");
48
+ });
49
+ });