pi-all-notification 1.0.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 ADDED
@@ -0,0 +1,120 @@
1
+ # pi-all-notification
2
+
3
+ pi 多渠道通知扩展 — macOS 桌面通知 / Bark 推送 / 自定义 Webhook。
4
+
5
+ ## 三种推送途径
6
+
7
+ | 渠道 | 说明 | 配置 |
8
+ |------|------|------|
9
+ | 🖥 **macOS 原生通知** | 右上角弹窗通知,基于 `osascript` | 零配置,开箱即用 |
10
+ | 📱 **Bark 推送** | 推送到 iPhone([Bark](https://github.com/Finb/Bark)) | 需配置设备 Key + Server |
11
+ | 🔗 **自定义 Webhook** | 任意 HTTP 端点(Slack/Discord/飞书/钉钉/企业微信…) | 配置 URL + Method + Body 模板 |
12
+
13
+ ## 安装
14
+
15
+ ```bash
16
+ pi install git:github.com/usaslahser/pi-all-notification
17
+ ```
18
+
19
+ 重启 pi(或 `/reload`)。
20
+
21
+ ## 使用
22
+
23
+ ### Agent 自动触发
24
+
25
+ Agent 完成响应时自动通知(`agent_end` 事件)。可在设置面板 toggle 触发时机。
26
+
27
+ ### Agent 手动调用
28
+
29
+ Agent 可主动调用 `notify_user` 工具发送通知:
30
+
31
+ ```
32
+ notify_user({
33
+ title: "Build Failed",
34
+ message: "TypeScript compilation failed with 12 errors.",
35
+ priority: "high"
36
+ })
37
+ ```
38
+
39
+ | 参数 | 说明 |
40
+ |------|------|
41
+ | `title` | 通知标题 |
42
+ | `message` | 通知内容 |
43
+ | `priority` | `normal` / `high` / `critical`(影响 emoji 前缀) |
44
+
45
+ ### `/all-notification` — 设置面板
46
+
47
+ ```
48
+ ↑↓ 导航 Enter 切换/进入子面板 Esc 退出
49
+ ```
50
+
51
+ 主面板 toggle 三个渠道开关,进入子面板配置参数。
52
+
53
+ ### Bark 配置
54
+
55
+ | 参数 | 默认值 | 说明 |
56
+ |------|--------|------|
57
+ | `key` | (必填) | Bark 设备 Key |
58
+ | `server` | `https://api.day.app` | Bark 服务器地址 |
59
+ | `authUser` / `authPass` | 无 | Basic Auth(自建 Bark 服务端用) |
60
+ | `sound` | `calypso` | 推送铃声:`calypso` `alarm` `birdsong` `chime` `complete` `electronic` 等 |
61
+ | `group` | `pi` | 通知分组名 |
62
+ | `level` | `active` | iOS 通知级别:`active` / `timeSensitive` / `passive` |
63
+ | `url` | 无 | 点击通知跳转的 URL |
64
+ | `isArchive` | `1` | 是否存入历史:`1` 是 / `0` 否 |
65
+
66
+ ### Webhook 配置
67
+
68
+ Webhook 支持 GET 和 POST,Body 模板支持变量替换:
69
+
70
+ | 变量 | 说明 |
71
+ |------|------|
72
+ | `{{title}}` | 通知标题,如 "pi ✅ Complete" |
73
+ | `{{message}}` | 通知内容,通常是任务描述 |
74
+ | `{{project}}` | 当前项目名 |
75
+ | `{{timestamp}}` | ISO 时间戳 |
76
+
77
+ **示例 — Slack Incoming Webhook:**
78
+ ```json
79
+ {
80
+ "method": "POST",
81
+ "url": "https://hooks.slack.com/services/xxx",
82
+ "bodyTemplate": "{\"text\": \"{{title}}: {{message}}\"}"
83
+ }
84
+ ```
85
+
86
+ **示例 — 飞书机器人:**
87
+ ```json
88
+ {
89
+ "method": "POST",
90
+ "url": "https://open.feishu.cn/open-apis/bot/v2/hook/xxx",
91
+ "bodyTemplate": "{\"msg_type\":\"text\",\"content\":{\"text\":\"{{title}}\\n{{message}}\"}}"
92
+ }
93
+ ```
94
+
95
+ **示例 — 钉钉机器人:**
96
+ ```json
97
+ {
98
+ "method": "POST",
99
+ "url": "https://oapi.dingtalk.com/robot/send?access_token=xxx",
100
+ "bodyTemplate": "{\"msgtype\":\"text\",\"text\":{\"content\":\"{{title}}\\n{{message}}\"}}"
101
+ }
102
+ ```
103
+
104
+ ### 触发时机
105
+
106
+ | 事件 | 默认 | 说明 |
107
+ |------|------|------|
108
+ | `agent_end` | ✅ | Agent 完成响应,等待输入 |
109
+ | `agent_start` | ⬜ | Agent 开始处理 |
110
+ | `tool_error` | ⬜ | 工具执行出错 |
111
+
112
+ ## 配置文件
113
+
114
+ 运行时配置存储在 `~/.pi/agent/extensions/pi-all-notification/config.json`。
115
+
116
+ 首次启动自动生成默认配置。仓库中的 `config.json` 是模板,不含密钥。
117
+
118
+ ## License
119
+
120
+ MIT
package/config.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "channels": {
3
+ "macos": { "enabled": true },
4
+ "bark": {
5
+ "enabled": false,
6
+ "key": "",
7
+ "server": "https://api.day.app",
8
+ "sound": "calypso",
9
+ "group": "pi",
10
+ "level": "active",
11
+ "isArchive": 1
12
+ },
13
+ "webhook": {
14
+ "enabled": false,
15
+ "url": "",
16
+ "method": "POST",
17
+ "headers": { "Content-Type": "application/json" },
18
+ "bodyTemplate": "{\"title\":\"{{title}}\",\"message\":\"{{message}}\",\"project\":\"{{project}}\",\"timestamp\":\"{{timestamp}}\"}"
19
+ }
20
+ },
21
+ "triggers": {
22
+ "agent_end": true,
23
+ "agent_start": false,
24
+ "tool_error": false
25
+ }
26
+ }
@@ -0,0 +1,812 @@
1
+ /**
2
+ * pi-all-notification — 多渠道通知扩展
3
+ *
4
+ * 三种推送途径:
5
+ * 1. macOS 桌面通知(右上角弹窗,osascript)
6
+ * 2. Bark 推送到 iPhone
7
+ * 3. 自定义 Webhook(支持 GET/POST + 模板变量 + 自定义 headers)
8
+ *
9
+ * 配置:~/.pi/agent/extensions/pi-all-notification/config.json
10
+ * 命令:/all-notification 打开设置面板
11
+ */
12
+
13
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { Container, DynamicBorder, Text } from "@earendil-works/pi-tui";
15
+ import { StringEnum } from "@earendil-works/pi-ai";
16
+ import { Type } from "typebox";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { execSync } from "node:child_process";
21
+
22
+ // ─── Types ────────────────────────────────────────────────────
23
+
24
+ interface ChannelConfig {
25
+ enabled: boolean;
26
+ }
27
+
28
+ interface BarkConfig extends ChannelConfig {
29
+ key: string;
30
+ server: string;
31
+ /** Basic auth: "user:pass" embedded in server URL, or separate fields */
32
+ authUser?: string;
33
+ authPass?: string;
34
+ sound: string;
35
+ group: string;
36
+ level: "active" | "timeSensitive" | "passive";
37
+ url?: string;
38
+ isArchive?: 1 | 0;
39
+ }
40
+
41
+ interface WebhookConfig extends ChannelConfig {
42
+ url: string;
43
+ method: "GET" | "POST";
44
+ headers: Record<string, string>;
45
+ bodyTemplate: string;
46
+ }
47
+
48
+ interface TriggerConfig {
49
+ agent_end: boolean;
50
+ agent_start: boolean;
51
+ tool_error: boolean;
52
+ }
53
+
54
+ interface FullConfig {
55
+ channels: {
56
+ macos: ChannelConfig;
57
+ bark: BarkConfig;
58
+ webhook: WebhookConfig;
59
+ };
60
+ triggers: TriggerConfig;
61
+ }
62
+
63
+ // ─── Defaults ─────────────────────────────────────────────────
64
+
65
+ const DEFAULT_CONFIG: FullConfig = {
66
+ channels: {
67
+ macos: { enabled: true },
68
+ bark: {
69
+ enabled: false,
70
+ key: "",
71
+ server: "https://api.day.app",
72
+ sound: "calypso",
73
+ group: "pi",
74
+ level: "active",
75
+ isArchive: 1,
76
+ },
77
+ webhook: {
78
+ enabled: false,
79
+ url: "",
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ bodyTemplate: JSON.stringify({
83
+ title: "{{title}}",
84
+ message: "{{message}}",
85
+ project: "{{project}}",
86
+ timestamp: "{{timestamp}}",
87
+ }, null, 2),
88
+ },
89
+ },
90
+ triggers: {
91
+ agent_end: true,
92
+ agent_start: false,
93
+ tool_error: false,
94
+ },
95
+ };
96
+
97
+ // ─── Config path & I/O ────────────────────────────────────────
98
+
99
+ const CONFIG_DIR = join(homedir(), ".pi", "agent", "extensions", "pi-all-notification");
100
+ const CONFIG_PATH = join(CONFIG_DIR, "config.json");
101
+
102
+ function loadConfig(): FullConfig {
103
+ if (existsSync(CONFIG_PATH)) {
104
+ try {
105
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
106
+ } catch { /* corrupt → use defaults */ }
107
+ }
108
+ return structuredClone(DEFAULT_CONFIG);
109
+ }
110
+
111
+ function saveConfig(cfg: FullConfig): void {
112
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
113
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
114
+ }
115
+
116
+ // ─── macOS notification ───────────────────────────────────────
117
+
118
+ function notifyMacOS(title: string, body: string): void {
119
+ if (process.platform !== "darwin") return;
120
+ try {
121
+ // ponytail: osascript is the simplest cross-version macOS notification API
122
+ const escapedTitle = title.replace(/"/g, '\\"');
123
+ const escapedBody = body.replace(/"/g, '\\"');
124
+ execSync(
125
+ `osascript -e 'display notification "${escapedBody}" with title "${escapedTitle}"'`,
126
+ { timeout: 3000 },
127
+ );
128
+ } catch {
129
+ // Silent — never block agent
130
+ }
131
+ }
132
+
133
+ // ─── Bark push ─────────────────────────────────────────────────
134
+
135
+ async function sendBark(cfg: BarkConfig, title: string, body: string): Promise<void> {
136
+ const server = (cfg.server || "https://api.day.app").replace(/\/+$/, "");
137
+
138
+ // Build query params
139
+ const params = new URLSearchParams();
140
+ params.set("sound", cfg.sound || "calypso");
141
+ params.set("group", cfg.group || "pi");
142
+ params.set("level", cfg.level || "active");
143
+ if (cfg.url) params.set("url", cfg.url);
144
+ if (cfg.isArchive !== undefined) params.set("isArchive", String(cfg.isArchive));
145
+
146
+ const url = `${server}/${encodeURIComponent(cfg.key)}/${encodeURIComponent(title)}/${encodeURIComponent(body)}?${params.toString()}`;
147
+
148
+ const ac = new AbortController();
149
+ const timer = setTimeout(() => ac.abort(), 5000);
150
+
151
+ try {
152
+ const headers: Record<string, string> = {};
153
+ // Handle Basic Auth if configured
154
+ if (cfg.authUser && cfg.authPass) {
155
+ const encoded = Buffer.from(`${cfg.authUser}:${cfg.authPass}`).toString("base64");
156
+ headers["Authorization"] = `Basic ${encoded}`;
157
+ }
158
+ await fetch(url, { signal: ac.signal, headers });
159
+ } catch {
160
+ // Silent
161
+ } finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
165
+
166
+ // ─── Custom webhook ────────────────────────────────────────────
167
+
168
+ function renderTemplate(template: string, vars: Record<string, string>): string {
169
+ let result = template;
170
+ for (const [key, value] of Object.entries(vars)) {
171
+ result = result.replaceAll(`{{${key}}}`, value);
172
+ }
173
+ return result;
174
+ }
175
+
176
+ async function sendWebhook(cfg: WebhookConfig, vars: Record<string, string>): Promise<void> {
177
+ const body = renderTemplate(cfg.bodyTemplate, vars);
178
+ const headers = { ...cfg.headers };
179
+
180
+ const ac = new AbortController();
181
+ const timer = setTimeout(() => ac.abort(), 5000);
182
+
183
+ try {
184
+ if (cfg.method === "GET") {
185
+ // GET: append body as query params if it parses as JSON
186
+ let url = cfg.url;
187
+ try {
188
+ const obj = JSON.parse(body);
189
+ const params = new URLSearchParams(obj).toString();
190
+ url += (url.includes("?") ? "&" : "?") + params;
191
+ } catch {
192
+ // body not valid JSON — just hit the URL as-is
193
+ }
194
+ await fetch(url, { signal: ac.signal, headers: { ...headers } });
195
+ } else {
196
+ await fetch(cfg.url, {
197
+ method: "POST",
198
+ signal: ac.signal,
199
+ headers,
200
+ body,
201
+ });
202
+ }
203
+ } catch {
204
+ // Silent
205
+ } finally {
206
+ clearTimeout(timer);
207
+ }
208
+ }
209
+
210
+ // ─── Notification dispatch ────────────────────────────────────
211
+
212
+ interface NotificationVars {
213
+ title: string;
214
+ message: string;
215
+ project: string;
216
+ emoji: string;
217
+ timestamp: string;
218
+ }
219
+
220
+ function fireAll(cfg: FullConfig, vars: NotificationVars): void {
221
+ const { channels } = cfg;
222
+
223
+ // 1. macOS
224
+ if (channels.macos.enabled) {
225
+ notifyMacOS(vars.title, vars.message);
226
+ }
227
+
228
+ // 2. Bark
229
+ if (channels.bark.enabled && channels.bark.key) {
230
+ sendBark(channels.bark, vars.title, vars.message).catch(() => {});
231
+ }
232
+
233
+ // 3. Webhook
234
+ if (channels.webhook.enabled && channels.webhook.url) {
235
+ sendWebhook(channels.webhook, {
236
+ title: vars.title,
237
+ message: vars.message,
238
+ project: vars.project,
239
+ timestamp: vars.timestamp,
240
+ }).catch(() => {});
241
+ }
242
+ }
243
+
244
+ // ─── TUI Settings Panel ───────────────────────────────────────
245
+
246
+ function settingLabel(emoji: string, name: string, enabled: boolean, extra?: string): string {
247
+ const check = enabled ? "✅" : "⬜";
248
+ return `${check} ${emoji} ${name}${extra ? ` ${extra}` : ""}`;
249
+ }
250
+
251
+ // ─── Main Extension ───────────────────────────────────────────
252
+
253
+ export default function (pi: ExtensionAPI) {
254
+ let projectName = "";
255
+
256
+ // ── notify_user agent tool ────────────────────────────
257
+ pi.registerTool({
258
+ name: "notify_user",
259
+ label: "Notify User",
260
+ description: "Send a notification to the user via all enabled channels (macOS, Bark, Webhook). Use when a long-running task completes, an error occurs, or you need the user's attention.",
261
+ promptSnippet: "Notify user via macOS/Bark/Webhook",
262
+ promptGuidelines: [
263
+ "Use notify_user when the user asked you to notify them on completion, or when a long task finishes while they may be away.",
264
+ "Use notify_user to alert the user about critical errors that need their attention.",
265
+ "Users configure notification channels via the /all-notification command — mention this if they ask about setup.",
266
+ ],
267
+ parameters: Type.Object({
268
+ title: Type.String({ description: "Notification title (keep short, e.g. 'Build Complete')" }),
269
+ message: Type.String({ description: "Notification body (task summary, error detail, etc.)" }),
270
+ priority: Type.Optional(StringEnum(["normal", "high", "critical"] as const)),
271
+ }),
272
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
273
+ const cfg = loadConfig();
274
+ const priorityEmoji: Record<string, string> = {
275
+ high: "⚠️",
276
+ critical: "🚨",
277
+ };
278
+ const emoji = priorityEmoji[params.priority ?? "normal"] ?? "🔔";
279
+
280
+ const vars: NotificationVars = {
281
+ title: `${emoji} ${params.title}`,
282
+ message: params.message,
283
+ project: projectName,
284
+ emoji,
285
+ timestamp: new Date().toISOString(),
286
+ };
287
+ fireAll(cfg, vars);
288
+
289
+ const active: string[] = [];
290
+ if (cfg.channels.macos.enabled) active.push("macOS");
291
+ if (cfg.channels.bark.enabled && cfg.channels.bark.key) active.push("Bark");
292
+ if (cfg.channels.webhook.enabled && cfg.channels.webhook.url) active.push("Webhook");
293
+
294
+ return {
295
+ content: [{ type: "text", text: `Notification sent to: ${active.join(", ") || "(no channels enabled)"}. Title: "${params.title}"` }],
296
+ details: { channels: active, title: params.title, message: params.message },
297
+ };
298
+ },
299
+ });
300
+
301
+ // ── /all-notification settings panel ─────────────────────
302
+ pi.registerCommand("all-notification", {
303
+ description: "Configure notification channels: macOS, Bark, Webhook",
304
+ handler: async (_args, ctx) => {
305
+ const cfg = loadConfig();
306
+
307
+ function buildItems() {
308
+ const b = cfg.channels.bark;
309
+ const w = cfg.channels.webhook;
310
+ return [
311
+ { label: settingLabel("🖥", "macOS Notification", cfg.channels.macos.enabled), value: "macos_toggle" },
312
+ { label: settingLabel("📱", "Bark Push", b.enabled, b.key ? `(${b.key.slice(0, 8)}...)` : "(not set)"), value: "bark" },
313
+ { label: settingLabel("🔗", "Webhook", w.enabled, w.url ? `(${truncateUrl(w.url)})` : "(not set)"), value: "webhook" },
314
+ { label: `⚙ Triggers`, value: "triggers" },
315
+ ];
316
+ }
317
+
318
+ let lastIndex = 0;
319
+
320
+ while (true) {
321
+ const result = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: number | undefined) => void) => {
322
+ let cur = lastIndex;
323
+
324
+ const container = new Container();
325
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
326
+ container.addChild(new Text(
327
+ theme.fg("accent", theme.bold("pi-all-notification")) +
328
+ theme.fg("dim", " ↑↓ navigate Enter toggle/configure Esc exit"),
329
+ 1, 0,
330
+ ));
331
+ const listC = new Container();
332
+ container.addChild(listC);
333
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
334
+
335
+ function render() {
336
+ listC.clear();
337
+ const items = buildItems();
338
+ for (let i = 0; i < items.length; i++) {
339
+ const line = i === cur
340
+ ? theme.fg("accent", "→ " + items[i].label)
341
+ : " " + items[i].label;
342
+ listC.addChild(new Text(line, 1, 0));
343
+ }
344
+ }
345
+ render();
346
+
347
+ return {
348
+ render: (w: any) => container.render(w),
349
+ invalidate: () => container.invalidate(),
350
+ handleInput: (data: string) => {
351
+ const items = buildItems();
352
+
353
+ if (data === "\x1b[A" || data === "\x1bOA" || data === "\x1b[[A") {
354
+ cur = cur === 0 ? items.length - 1 : cur - 1;
355
+ render();
356
+ tui.requestRender();
357
+ } else if (data === "\x1b[B" || data === "\x1bOB" || data === "\x1b[[B") {
358
+ cur = cur === items.length - 1 ? 0 : cur + 1;
359
+ render();
360
+ tui.requestRender();
361
+ } else if (data === "\r" || data === "\n" || data === "\x1bOM") {
362
+ const action = items[cur].value;
363
+ if (action === "macos_toggle") {
364
+ cfg.channels.macos.enabled = !cfg.channels.macos.enabled;
365
+ saveConfig(cfg);
366
+ render();
367
+ tui.requestRender();
368
+ } else {
369
+ lastIndex = cur;
370
+ done(cur);
371
+ }
372
+ } else if (data === "\x1b") {
373
+ done(undefined);
374
+ }
375
+ },
376
+ };
377
+ });
378
+
379
+ if (result === undefined) break;
380
+
381
+ const items = buildItems();
382
+ const action = items[result].value;
383
+
384
+ // ── Sub-panels ────────────────────────────────────
385
+
386
+ if (action === "bark") {
387
+ await barkSubpanel(ctx, cfg);
388
+ } else if (action === "webhook") {
389
+ await webhookSubpanel(ctx, cfg);
390
+ } else if (action === "triggers") {
391
+ await triggersSubpanel(ctx, cfg);
392
+ }
393
+ }
394
+ },
395
+ });
396
+
397
+ // ── Bark sub-panel ──────────────────────────────────────
398
+ async function barkSubpanel(ctx: any, cfg: FullConfig) {
399
+ while (true) {
400
+ const b = cfg.channels.bark;
401
+ const authInfo = b.authUser ? ` (${b.authUser}:***)` : "";
402
+ const items = [
403
+ { label: settingLabel("📱", "Bark enabled", b.enabled), value: "toggle" },
404
+ { label: `🔑 Key: ${b.key || "(not set)"}`, value: "key" },
405
+ { label: `🌐 Server: ${b.server}${authInfo}`, value: "server" },
406
+ { label: `🔐 Auth: ${b.authUser ? `${b.authUser}:***` : "(none)"}`, value: "auth" },
407
+ { label: `🔊 Sound: ${b.sound}`, value: "sound" },
408
+ { label: `📦 Group: ${b.group}`, value: "group" },
409
+ { label: `⚡ Level: ${b.level}`, value: "level" },
410
+ { label: `🔗 Click URL: ${b.url || "(none)"}`, value: "url" },
411
+ { label: `📥 isArchive: ${b.isArchive ?? 1}`, value: "isArchive" },
412
+ { label: "← Back", value: "back" },
413
+ ];
414
+
415
+ let cur = 0;
416
+ const choice = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: number | undefined) => void) => {
417
+ const container = new Container();
418
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
419
+ container.addChild(new Text(
420
+ theme.fg("accent", theme.bold("Bark Configuration")) +
421
+ theme.fg("dim", " ↑↓ select Enter confirm Esc back"),
422
+ 1, 0,
423
+ ));
424
+ const listC = new Container();
425
+ container.addChild(listC);
426
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
427
+
428
+ function render() {
429
+ listC.clear();
430
+ for (let i = 0; i < items.length; i++) {
431
+ const line = i === cur
432
+ ? theme.fg("accent", "→ " + items[i].label)
433
+ : " " + items[i].label;
434
+ listC.addChild(new Text(line, 1, 0));
435
+ }
436
+ }
437
+ render();
438
+
439
+ return {
440
+ render: (w: any) => container.render(w),
441
+ invalidate: () => container.invalidate(),
442
+ handleInput: (data: string) => {
443
+ if (data === "\x1b[A" || data === "\x1bOA" || data === "\x1b[[A") {
444
+ cur = cur === 0 ? items.length - 1 : cur - 1; render(); tui.requestRender();
445
+ } else if (data === "\x1b[B" || data === "\x1bOB" || data === "\x1b[[B") {
446
+ cur = cur === items.length - 1 ? 0 : cur + 1; render(); tui.requestRender();
447
+ } else if (data === "\r" || data === "\n" || data === "\x1bOM") {
448
+ done(cur);
449
+ } else if (data === "\x1b") {
450
+ done(undefined);
451
+ }
452
+ },
453
+ };
454
+ });
455
+
456
+ if (choice === undefined) break;
457
+
458
+ if (items[choice].value === "back") break;
459
+ if (items[choice].value === "toggle") {
460
+ b.enabled = !b.enabled;
461
+ saveConfig(cfg);
462
+ }
463
+ if (items[choice].value === "key") {
464
+ const k = await ctx.ui.input("Bark device key:", b.key || "");
465
+ if (k !== undefined && k.trim()) { b.key = k.trim(); saveConfig(cfg); ctx.ui.notify("Bark key saved", "info"); }
466
+ }
467
+ if (items[choice].value === "server") {
468
+ const s = await ctx.ui.input("Bark server URL:", b.server);
469
+ if (s !== undefined && s.trim()) { b.server = s.trim(); saveConfig(cfg); ctx.ui.notify("Bark server saved", "info"); }
470
+ }
471
+ if (items[choice].value === "auth") {
472
+ const u = await ctx.ui.input("Auth username (leave empty to clear):", b.authUser || "");
473
+ if (u === undefined) continue;
474
+ if (!u.trim()) { delete b.authUser; delete b.authPass; saveConfig(cfg); ctx.ui.notify("Auth cleared", "info"); continue; }
475
+ b.authUser = u.trim();
476
+ const p = await ctx.ui.input("Auth password:", b.authPass || "");
477
+ if (p !== undefined) { b.authPass = p; saveConfig(cfg); ctx.ui.notify("Auth saved", "info"); }
478
+ }
479
+ if (items[choice].value === "sound") {
480
+ const s = await ctx.ui.input("Sound (e.g. calypso, alarm, birdsong):", b.sound);
481
+ if (s !== undefined && s.trim()) { b.sound = s.trim(); saveConfig(cfg); ctx.ui.notify(`Sound: ${b.sound}`, "info"); }
482
+ }
483
+ if (items[choice].value === "group") {
484
+ const g = await ctx.ui.input("Group name:", b.group);
485
+ if (g !== undefined && g.trim()) { b.group = g.trim(); saveConfig(cfg); ctx.ui.notify(`Group: ${b.group}`, "info"); }
486
+ }
487
+ if (items[choice].value === "level") {
488
+ const levels: Array<"active" | "timeSensitive" | "passive"> = ["active", "timeSensitive", "passive"];
489
+ const idx = levels.indexOf(b.level);
490
+ b.level = levels[(idx + 1) % levels.length];
491
+ saveConfig(cfg);
492
+ ctx.ui.notify(`Level: ${b.level}`, "info");
493
+ }
494
+ if (items[choice].value === "url") {
495
+ const u = await ctx.ui.input("Click URL (leave empty to clear):", b.url || "");
496
+ if (u === undefined) continue;
497
+ b.url = u.trim() || undefined;
498
+ saveConfig(cfg);
499
+ ctx.ui.notify(b.url ? "Click URL saved" : "Click URL cleared", "info");
500
+ }
501
+ if (items[choice].value === "isArchive") {
502
+ b.isArchive = b.isArchive === 1 ? 0 : 1;
503
+ saveConfig(cfg);
504
+ ctx.ui.notify(`isArchive: ${b.isArchive}`, "info");
505
+ }
506
+ }
507
+ }
508
+
509
+ // ── Webhook sub-panel ───────────────────────────────────
510
+ async function webhookSubpanel(ctx: any, cfg: FullConfig) {
511
+ while (true) {
512
+ const w = cfg.channels.webhook;
513
+ const items = [
514
+ { label: settingLabel("🔗", "Webhook enabled", w.enabled), value: "toggle" },
515
+ { label: `🌐 URL: ${w.url || "(not set)"}`, value: "url" },
516
+ { label: `📡 Method: ${w.method}`, value: "method" },
517
+ { label: `✏️ Edit body template`, value: "body" },
518
+ { label: `📋 Headers (${Object.keys(w.headers).length})`, value: "headers" },
519
+ { label: `🧪 Test webhook`, value: "test" },
520
+ { label: "← Back", value: "back" },
521
+ ];
522
+
523
+ let cur = 0;
524
+ const choice = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: number | undefined) => void) => {
525
+ const container = new Container();
526
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
527
+ container.addChild(new Text(
528
+ theme.fg("accent", theme.bold("Webhook Configuration")) +
529
+ theme.fg("dim", " ↑↓ select Enter confirm Esc back"),
530
+ 1, 0,
531
+ ));
532
+ const listC = new Container();
533
+ container.addChild(listC);
534
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
535
+
536
+ function render() {
537
+ listC.clear();
538
+ for (let i = 0; i < items.length; i++) {
539
+ const line = i === cur
540
+ ? theme.fg("accent", "→ " + items[i].label)
541
+ : " " + items[i].label;
542
+ listC.addChild(new Text(line, 1, 0));
543
+ }
544
+ }
545
+ render();
546
+
547
+ return {
548
+ render: (w: any) => container.render(w),
549
+ invalidate: () => container.invalidate(),
550
+ handleInput: (data: string) => {
551
+ if (data === "\x1b[A" || data === "\x1bOA" || data === "\x1b[[A") {
552
+ cur = cur === 0 ? items.length - 1 : cur - 1; render(); tui.requestRender();
553
+ } else if (data === "\x1b[B" || data === "\x1bOB" || data === "\x1b[[B") {
554
+ cur = cur === items.length - 1 ? 0 : cur + 1; render(); tui.requestRender();
555
+ } else if (data === "\r" || data === "\n" || data === "\x1bOM") {
556
+ done(cur);
557
+ } else if (data === "\x1b") {
558
+ done(undefined);
559
+ }
560
+ },
561
+ };
562
+ });
563
+
564
+ if (choice === undefined) break;
565
+
566
+ if (items[choice].value === "back") break;
567
+ if (items[choice].value === "toggle") { w.enabled = !w.enabled; saveConfig(cfg); }
568
+ if (items[choice].value === "url") {
569
+ const u = await ctx.ui.input("Webhook URL:", w.url || "");
570
+ if (u !== undefined && u.trim()) { w.url = u.trim(); saveConfig(cfg); ctx.ui.notify("Webhook URL saved", "info"); }
571
+ }
572
+ if (items[choice].value === "method") {
573
+ w.method = w.method === "POST" ? "GET" : "POST";
574
+ saveConfig(cfg);
575
+ ctx.ui.notify(`Method: ${w.method}`, "info");
576
+ }
577
+ if (items[choice].value === "body") {
578
+ const t = await ctx.ui.editor(w.bodyTemplate);
579
+ if (t !== undefined) { w.bodyTemplate = t; saveConfig(cfg); ctx.ui.notify("Body template saved", "info"); }
580
+ }
581
+ if (items[choice].value === "headers") {
582
+ await headersSubpanel(ctx, cfg);
583
+ }
584
+ if (items[choice].value === "test") {
585
+ if (!w.url) { ctx.ui.notify("Webhook URL not set", "warning"); continue; }
586
+ sendWebhook(w, {
587
+ title: "Test Notification",
588
+ message: "This is a test from pi-all-notification",
589
+ project: projectName || "test",
590
+ timestamp: new Date().toISOString(),
591
+ }).then(() => ctx.ui.notify("Webhook test sent", "info"))
592
+ .catch(() => ctx.ui.notify("Webhook test failed", "error"));
593
+ }
594
+ }
595
+ }
596
+
597
+ // ── Headers sub-panel ──────────────────────────────────
598
+ async function headersSubpanel(ctx: any, cfg: FullConfig) {
599
+ while (true) {
600
+ const w = cfg.channels.webhook;
601
+ const entries = Object.entries(w.headers);
602
+ const items = [
603
+ ...entries.map(([k, v]) => ({ label: ` ${k}: ${v}`, value: `edit:${k}` })),
604
+ { label: "+ Add header", value: "add" },
605
+ { label: "← Back", value: "back" },
606
+ ];
607
+
608
+ let cur = 0;
609
+ const choice = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: number | undefined) => void) => {
610
+ const container = new Container();
611
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
612
+ container.addChild(new Text(
613
+ theme.fg("accent", theme.bold("Custom Headers")) +
614
+ theme.fg("dim", " ↑↓ select Enter edit/delete Esc back"),
615
+ 1, 0,
616
+ ));
617
+ const listC = new Container();
618
+ container.addChild(listC);
619
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
620
+
621
+ function render() {
622
+ listC.clear();
623
+ for (let i = 0; i < items.length; i++) {
624
+ const line = i === cur
625
+ ? theme.fg("accent", "→ " + items[i].label)
626
+ : " " + items[i].label;
627
+ listC.addChild(new Text(line, 1, 0));
628
+ }
629
+ }
630
+ render();
631
+
632
+ return {
633
+ render: (w: any) => container.render(w),
634
+ invalidate: () => container.invalidate(),
635
+ handleInput: (data: string) => {
636
+ if (data === "\x1b[A" || data === "\x1bOA" || data === "\x1b[[A") {
637
+ cur = cur === 0 ? items.length - 1 : cur - 1; render(); tui.requestRender();
638
+ } else if (data === "\x1b[B" || data === "\x1bOB" || data === "\x1b[[B") {
639
+ cur = cur === items.length - 1 ? 0 : cur + 1; render(); tui.requestRender();
640
+ } else if (data === "\r" || data === "\n" || data === "\x1bOM") {
641
+ done(cur);
642
+ } else if (data === "\x1b") {
643
+ done(undefined);
644
+ }
645
+ },
646
+ };
647
+ });
648
+
649
+ if (choice === undefined) break;
650
+
651
+ const action = items[choice].value;
652
+ if (action === "back") break;
653
+
654
+ if (action === "add") {
655
+ const key = await ctx.ui.input("Header name:", "");
656
+ if (key === undefined || !key.trim()) continue;
657
+ const val = await ctx.ui.input(`Value for ${key.trim()}:`, "");
658
+ if (val === undefined) continue;
659
+ w.headers[key.trim()] = val;
660
+ saveConfig(cfg);
661
+ } else if (action.startsWith("edit:")) {
662
+ const key = action.slice(5);
663
+ const options = [
664
+ { label: `Edit value: ${w.headers[key]}`, value: "edit" },
665
+ { label: `🗑 Delete "${key}"`, value: "delete" },
666
+ ];
667
+ const subChoice = await ctx.ui.select(`Header "${key}"`, options);
668
+ if (subChoice === "edit") {
669
+ const val = await ctx.ui.input(`Value for ${key}:`, w.headers[key]);
670
+ if (val !== undefined) { w.headers[key] = val; saveConfig(cfg); }
671
+ } else if (subChoice === "delete") {
672
+ delete w.headers[key];
673
+ saveConfig(cfg);
674
+ }
675
+ }
676
+ }
677
+ }
678
+
679
+ // ── Triggers sub-panel ──────────────────────────────────
680
+ async function triggersSubpanel(ctx: any, cfg: FullConfig) {
681
+ while (true) {
682
+ const t = cfg.triggers;
683
+ const items = [
684
+ { label: settingLabel("🏁", "agent_end (response complete)", t.agent_end), value: "agent_end" },
685
+ { label: settingLabel("🚀", "agent_start (processing begins)", t.agent_start), value: "agent_start" },
686
+ { label: settingLabel("❌", "tool_error (tool execution error)", t.tool_error), value: "tool_error" },
687
+ { label: "← Back", value: "back" },
688
+ ];
689
+
690
+ let cur = 0;
691
+ const choice = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: number | undefined) => void) => {
692
+ const container = new Container();
693
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
694
+ container.addChild(new Text(
695
+ theme.fg("accent", theme.bold("Triggers")) +
696
+ theme.fg("dim", " ↑↓ select Enter toggle Esc back"),
697
+ 1, 0,
698
+ ));
699
+ const listC = new Container();
700
+ container.addChild(listC);
701
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
702
+
703
+ function render() {
704
+ listC.clear();
705
+ for (let i = 0; i < items.length; i++) {
706
+ const line = i === cur
707
+ ? theme.fg("accent", "→ " + items[i].label)
708
+ : " " + items[i].label;
709
+ listC.addChild(new Text(line, 1, 0));
710
+ }
711
+ }
712
+ render();
713
+
714
+ return {
715
+ render: (w: any) => container.render(w),
716
+ invalidate: () => container.invalidate(),
717
+ handleInput: (data: string) => {
718
+ if (data === "\x1b[A" || data === "\x1bOA" || data === "\x1b[[A") {
719
+ cur = cur === 0 ? items.length - 1 : cur - 1; render(); tui.requestRender();
720
+ } else if (data === "\x1b[B" || data === "\x1bOB" || data === "\x1b[[B") {
721
+ cur = cur === items.length - 1 ? 0 : cur + 1; render(); tui.requestRender();
722
+ } else if (data === "\r" || data === "\n" || data === "\x1bOM") {
723
+ done(cur);
724
+ } else if (data === "\x1b") {
725
+ done(undefined);
726
+ }
727
+ },
728
+ };
729
+ });
730
+
731
+ if (choice === undefined) break;
732
+
733
+ const action = items[choice].value;
734
+ if (action === "back") break;
735
+ if (action === "agent_end") { t.agent_end = !t.agent_end; saveConfig(cfg); }
736
+ if (action === "agent_start") { t.agent_start = !t.agent_start; saveConfig(cfg); }
737
+ if (action === "tool_error") { t.tool_error = !t.tool_error; saveConfig(cfg); }
738
+ }
739
+ }
740
+
741
+ // ── Lifecycle events ────────────────────────────────────
742
+ pi.on("session_start", (_event, ctx) => {
743
+ // Initialize config if not exists
744
+ if (!existsSync(CONFIG_PATH)) {
745
+ saveConfig(DEFAULT_CONFIG);
746
+ }
747
+ projectName = ctx.cwd.split(/[/\\]/).pop() || "";
748
+ });
749
+
750
+ pi.on("agent_start", (_event, ctx) => {
751
+ projectName = ctx.cwd.split(/[/\\]/).pop() || "";
752
+ const cfg = loadConfig();
753
+ if (!cfg.triggers.agent_start) return;
754
+
755
+ const vars: NotificationVars = {
756
+ title: "pi 🤖 Processing",
757
+ message: `Agent started — ${projectName}`,
758
+ project: projectName,
759
+ emoji: "🤖",
760
+ timestamp: new Date().toISOString(),
761
+ };
762
+ fireAll(cfg, vars);
763
+ });
764
+
765
+ pi.on("agent_end", (event, _ctx) => {
766
+ const cfg = loadConfig();
767
+ if (!cfg.triggers.agent_end) return;
768
+
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
+
782
+ const vars: NotificationVars = {
783
+ title: "pi ✅ Complete",
784
+ message: taskDesc,
785
+ project: projectName,
786
+ emoji: "✅",
787
+ timestamp: new Date().toISOString(),
788
+ };
789
+ fireAll(cfg, vars);
790
+ });
791
+
792
+ pi.on("tool_execution_end", (event, _ctx) => {
793
+ const cfg = loadConfig();
794
+ if (!cfg.triggers.tool_error) return;
795
+ if (!event.isError) return;
796
+
797
+ const vars: NotificationVars = {
798
+ title: "pi ❌ Tool Error",
799
+ message: `${event.toolName}: ${String(event.result).slice(0, 80)}`,
800
+ project: projectName,
801
+ emoji: "❌",
802
+ timestamp: new Date().toISOString(),
803
+ };
804
+ fireAll(cfg, vars);
805
+ });
806
+ }
807
+
808
+ // ─── Helpers ───────────────────────────────────────────────────
809
+
810
+ function truncateUrl(url: string, max = 40): string {
811
+ return url.length > max ? url.slice(0, max - 3) + "..." : url;
812
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "pi-all-notification",
3
+ "version": "1.0.0",
4
+ "description": "Multi-channel notification for pi: macOS desktop, Bark push, custom webhook",
5
+ "keywords": ["pi-package", "pi-extension", "notification"],
6
+ "author": "Slahser",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/usaslahser/pi-all-notification.git"
11
+ },
12
+ "pi": {
13
+ "extensions": ["./extensions/index.ts"]
14
+ },
15
+ "peerDependencies": {
16
+ "@earendil-works/pi-coding-agent": ">=0.1.0"
17
+ }
18
+ }