pi-all-notification 1.0.3 → 1.0.5

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
@@ -22,7 +22,9 @@ pi install npm:pi-all-notification
22
22
 
23
23
  ### Agent 自动触发
24
24
 
25
- Agent 完成响应时自动通知(`agent_end` 事件)。可在设置面板 toggle 触发时机。
25
+ 一轮运行**最终结束**时自动通知(Pi 的 `agent_settled` 事件)。自然结束发 `✅ Complete`,被用户取消发 `⏹ Cancelled`,两者不会混为一谈。可在设置面板 toggle 触发时机。
26
+
27
+ > 用的是 `agent_settled` 而不是 `agent_end`:`agent_end` 之后还可能跟着重试、压缩恢复或排队的消息,在那里发通知既可能过早,也无法区分「完成」和「被取消」。
26
28
 
27
29
  ### Agent 手动调用
28
30
 
@@ -105,7 +107,7 @@ Webhook 支持 GET 和 POST,Body 模板支持变量替换:
105
107
 
106
108
  | 事件 | 默认 | 说明 |
107
109
  |------|------|------|
108
- | `agent_end` | ✅ | Agent 完成响应,等待输入 |
110
+ | `agent_end` | ✅ | 一轮运行结束(自然完成 `✅` 或被取消 `⏹`) |
109
111
  | `agent_start` | ⬜ | Agent 开始处理 |
110
112
  | `tool_error` | ⬜ | 工具执行出错 |
111
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 { describeBeforeAgentStart, 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 = describeBeforeAgentStart(event);
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,75 @@
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
+ * Task text from `before_agent_start`.
27
+ *
28
+ * Pi 1.3 also fires this event for runs started by a custom message, where the
29
+ * event carries `message` instead of `prompt`.
30
+ */
31
+ export function describeBeforeAgentStart(event: unknown): string | null {
32
+ if (!event || typeof event !== "object") return null;
33
+ const { prompt, message } = event as { prompt?: unknown; message?: unknown };
34
+ const fromPrompt = describeTask(prompt);
35
+ if (fromPrompt) return fromPrompt;
36
+ if (!message || typeof message !== "object") return null;
37
+ const content = (message as { content?: unknown }).content;
38
+ if (typeof content === "string") return describeTask(content);
39
+ if (!Array.isArray(content)) return null;
40
+ const text = content
41
+ .map((item) =>
42
+ item && typeof item === "object" && (item as { type?: unknown }).type === "text"
43
+ ? (item as { text?: unknown }).text
44
+ : undefined,
45
+ )
46
+ .filter((item): item is string => typeof item === "string")
47
+ .join("\n");
48
+ return describeTask(text);
49
+ }
50
+
51
+ /**
52
+ * The notification for a settled run.
53
+ *
54
+ * A cancelled run says so instead of reporting success: `aborted` distinguishes
55
+ * a run the person stopped from one that finished on its own, and calling a
56
+ * cancellation "Complete" is the bug this guards.
57
+ */
58
+ export function settleNotification(
59
+ event: { aborted?: unknown },
60
+ projectName: string,
61
+ taskDescription: string | null,
62
+ ): SettleNotification {
63
+ if (event?.aborted === true) {
64
+ return {
65
+ title: "pi ⏹ Cancelled",
66
+ message: `Run cancelled — ${projectName}`,
67
+ emoji: "⏹",
68
+ };
69
+ }
70
+ return {
71
+ title: "pi ✅ Complete",
72
+ message: taskDescription ?? projectName,
73
+ emoji: "✅",
74
+ };
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-all-notification",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Multi-channel notification for pi: macOS desktop, Bark push, custom webhook",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,6 +19,12 @@
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
29
  "@earendil-works/pi-coding-agent": "^1.1.0"
24
30
  }
@@ -0,0 +1,81 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { describeBeforeAgentStart, 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("describeBeforeAgentStart", () => {
26
+ it("reads the prompt from a user run", () => {
27
+ assert.equal(describeBeforeAgentStart({ messageType: "user", prompt: "Fix the build" }), "Fix the build");
28
+ });
29
+
30
+ it("reads text content from a custom-message run", () => {
31
+ const event = {
32
+ messageType: "custom",
33
+ message: { role: "custom", content: [{ type: "text", text: "Review the release\nand tag it" }] },
34
+ };
35
+ assert.equal(describeBeforeAgentStart(event), "Review the release");
36
+ });
37
+
38
+ it("reads string content from a custom-message run", () => {
39
+ const event = {
40
+ messageType: "custom",
41
+ message: { role: "custom", content: "Review the release" },
42
+ };
43
+ assert.equal(describeBeforeAgentStart(event), "Review the release");
44
+ });
45
+
46
+ it("ignores non-text custom content", () => {
47
+ assert.equal(
48
+ describeBeforeAgentStart({
49
+ messageType: "custom",
50
+ message: { role: "custom", content: [{ type: "image", data: "..." }] },
51
+ }),
52
+ null,
53
+ );
54
+ });
55
+ });
56
+
57
+ describe("settleNotification", () => {
58
+ it("reports a natural finish as complete", () => {
59
+ const note = settleNotification({ aborted: false }, "pi-plugins", "Run the tests");
60
+ assert.equal(note.emoji, "✅");
61
+ assert.match(note.title, /Complete/);
62
+ assert.equal(note.message, "Run the tests");
63
+ });
64
+
65
+ it("reports a cancelled run as cancelled, not complete", () => {
66
+ const note = settleNotification({ aborted: true }, "pi-plugins", "Run the tests");
67
+ assert.equal(note.emoji, "⏹");
68
+ assert.match(note.title, /Cancelled/);
69
+ assert.match(note.message, /cancelled/i);
70
+ });
71
+
72
+ it("falls back to the project name when no prompt was captured", () => {
73
+ const note = settleNotification({ aborted: false }, "pi-plugins", null);
74
+ assert.equal(note.message, "pi-plugins");
75
+ });
76
+
77
+ it("treats an absent aborted flag as a natural finish", () => {
78
+ const note = settleNotification({}, "pi-plugins", "task");
79
+ assert.equal(note.emoji, "✅");
80
+ });
81
+ });