9 Commits
Author SHA1 Message Date
杨豪 6070849fae chore: release v1.1.0
Publish pi-ntfy to npm / publish (push) Successful in 17s
2026-10-08 14:17:31 +08:00
杨豪 6fc9f228a8 feat: default to all modes, switch mode filter from whitelist to excludedModes blacklist
- NotifyConfig.modes (whitelist) → excludedModes (blacklist, default [] = push everywhere)
- valid modes are tui/json/rpc/print (pi ExtensionMode); invalid entries ignored with warning
- legacy "modes" key is ignored with a deprecation warning
- /ntfy status shows 模式: 全部(排除: json)
- migrate local config: modes array → excludedModes: []
2026-10-08 14:16:48 +08:00
杨豪 7164408d74 chore: release v1.0.3
Publish pi-ntfy to npm / publish (push) Successful in 17s
2026-09-18 15:57:07 +08:00
杨豪 c16ae3a11d fix: publish ntfy as JSON so Chinese titles honor titleTemplate
Non-ASCII rendered titles were silently replaced by a hardcoded ASCII
fallback (fetch headers are Latin-1 only), making titleTemplate appear
to not work. JSON publishing carries title/message as UTF-8 body.
2026-09-18 15:43:26 +08:00
杨豪 a4efba3f1f chore: bump version to 1.0.2
Publish pi-ntfy to npm / publish (push) Successful in 16s
2026-09-17 11:14:30 +08:00
杨豪 2f9f865d17 docs: document configurable notification templates 2026-09-17 11:05:32 +08:00
杨豪 4628ee02d7 fix: use agent config directory 2026-09-17 10:05:45 +08:00
杨豪 42b20c42d6 fix: use fixed pi-ntfy config path 2026-09-17 10:02:28 +08:00
杨豪 65cde39b1b fix: publish without missing test script
Publish pi-ntfy to npm / publish (push) Successful in 18s
2026-09-17 09:53:17 +08:00
11 changed files with 149 additions and 119 deletions
+1 -1
View File
@@ -41,4 +41,4 @@
本插件只有一个通道:`ntfy`。其他推送通道和对应实现已移除。
NTFY 元数据使用 `Title`、`Tags`、`X-Project`、`X-Session`、`X-Event`、`X-Task` headers。含中文的字段会写入 Base64 版本的 headers,完整中文内容始终保留在正文中。
NTFY 采用 JSON 发布(POST 到 server 根路径):`title`、`message` 随 UTF-8 JSON body 传输,中文标题/正文不受 HTTP header 只允许 Latin-1 的限制;`tags`/`priority`/`click`/`icon`/`email` 同样走 JSON 字段。
+56 -17
View File
@@ -15,33 +15,56 @@ Pi coding agent 的 NTFY 专用通知插件,适合并行开发时区分项目
## 安装
```bash
pi remove /home/yanghao05/Workspace/pi-push
pi install /home/yanghao05/Workspace/pi-push
pi install npm:pi-ntfy
```
不要同时安装或启用旧版 `npm:pi-agent-push`。
卸载:
```bash
pi remove npm:pi-ntfy
```
## 配置
```bash
cp config.example.json config.json
插件固定从以下路径读取配置,不再通过环境变量选择配置文件:
```text
~/.pi/agent/pi-ntfy.json
```
默认配置:
如果文件不存在,插件保持静默;可直接创建该文件,或使用 `/ntfy set`、`/ntfy enable` 等命令生成和修改配置。
示例配置:
```json
{
"enabled": true,
"excludedModes": [],
"titleTemplate": "[{{project}}] {{status}} · {{session}}",
"template": "项目: {{project}}\\n会话: {{session}}\\n任务: {{task}}\\n状态: {{status}}\\n原因: {{reason}}\\n摘要: {{summary}}\\n耗时: {{duration}}\\n模型: {{model}}\\n主机: {{host}}\\n时间: {{date}} {{time}}\\n工具: {{tools}}",
"channels": [
{
"type": "ntfy",
"name": "Agent",
"enabled": true,
"topic": "Agent",
"server": "https://n.ipao.vip",
"token": "$NTFY_TOKEN"
}
]
}
```
`token` 可以使用 `$NTFY_TOKEN` 或 `${NTFY_TOKEN}` 引用环境变量;配置文件路径本身不受环境变量影响。
工作模式:默认支持 pi 的全部工作模式(`tui`、`json`、`rpc`、`print`),配置里无需列出支持的模式;`excludedModes` 仅列出**不**推送的模式,例如 `"excludedModes": ["json"]` 可让 `pi --mode json -p` 子代理保持安静。旧配置中的 `modes` 白名单已废弃,会被忽略并给出提示。
配置字段:
```json
{
"titleTemplate": "[{{project}}] {{status}} · {{session}}",
"template": "项目: {{project}}\n会话: {{session}}\n任务: {{task}}\n状态: {{status}}\n原因: {{reason}}\n摘要: {{summary}}\n耗时: {{duration}}\n模型: {{model}}\n主机: {{host}}\n时间: {{date}} {{time}}\n工具: {{tools}}",
"channels": [
{
"type": "ntfy",
"enabled": true,
"topic": "$NTFY_TOPIC",
"server": "$NTFY_HOST",
"token": "$NTFY_TOKEN"
}
]
"template": "项目: {{project}}\\n会话: {{session}}\\n任务: {{task}}\\n状态: {{status}}\\n原因: {{reason}}\\n摘要: {{summary}}\\n耗时: {{duration}}\\n模型: {{model}}\\n主机: {{host}}\\n时间: {{date}} {{time}}\\n工具: {{tools}}"
}
```
@@ -69,4 +92,20 @@ cp config.example.json config.json
/ntfy off
```
旧的 `/push` 命令不再使用。
## 发布
版本发布由 Gitea Actions 完成。推送 `v*` 标签或手动运行 `Publish pi-ntfy to npm` workflow 即可发布到 npm。
workflow 使用账户级 Secret:
```text
NPM_PACKAGE_TOKEN
```
例如:
```bash
npm version patch
git push
git push --tags
```
+19 -42
View File
@@ -8,7 +8,7 @@
import { httpRequest, localFailure, type HttpResult } from "../http.ts";
import type { NotifyPayload, NtfyChannel } from "../types.ts";
const DEFAULT_SERVER = "https://ntfy.sh";
const DEFAULT_SERVER = "https://n.ipao.vip";
function eventTagFor(event: NotifyPayload["event"]): string {
switch (event) {
@@ -25,14 +25,6 @@ function eventTagFor(event: NotifyPayload["event"]): string {
}
}
function isAscii(value: string): boolean {
return [...value].every((char) => char.charCodeAt(0) <= 255);
}
function base64(value: string): string {
return Buffer.from(value, "utf8").toString("base64url");
}
function addErrorDetail(result: HttpResult): HttpResult {
if (result.ok) return result;
let detail = result.error ?? `HTTP ${result.status}`;
@@ -66,44 +58,29 @@ export async function sendNtfy(
: `Bearer ${token}`;
}
const project = payload.vars.project ?? "";
const session = payload.vars.session ?? "";
const task = payload.vars.task ?? "";
const asciiSession = isAscii(session) ? session : "";
const asciiTitle = isAscii(payload.title)
? payload.title
: `[${project || "pi"}] pi ${payload.event}${asciiSession ? ` · ${asciiSession}` : ""}`;
// NTFY headers must be ASCII-safe under Node's fetch implementation.
headers.Title = asciiTitle;
headers["X-Event"] = payload.event;
if (isAscii(project)) headers["X-Project"] = project;
if (asciiSession) headers["X-Session"] = asciiSession;
if (isAscii(task) && task) headers["X-Task"] = task;
if (project) headers["X-Project-B64"] = base64(project);
if (session) headers["X-Session-B64"] = base64(session);
if (task) headers["X-Task-B64"] = base64(task);
if (channel.priority != null && channel.priority !== "") {
headers.Priority = String(channel.priority);
}
// Publish as JSON (POST to the server root): title/message travel in a UTF-8
// body, so Chinese titles work — plain-text publishing would force headers to
// be Latin-1 (fetch ByteString) and we had to mangle non-ASCII titles.
const configuredTags =
channel.tags == null
? ""
? []
: Array.isArray(channel.tags)
? channel.tags.join(",")
: String(channel.tags);
const tags = [configuredTags, eventTagFor(payload.event)]
.filter(Boolean)
.join(",");
if (tags) headers.Tags = tags;
if (channel.clickUrl) headers.Click = channel.clickUrl;
if (channel.icon) headers.Icon = channel.icon;
if (channel.email) headers.Email = channel.email;
? channel.tags
: [String(channel.tags)];
const body = JSON.stringify({
topic,
title: payload.title,
message: payload.text,
tags: [...configuredTags, eventTagFor(payload.event)],
...(channel.priority != null && channel.priority !== "" && { priority: channel.priority }),
...(channel.clickUrl && { click: channel.clickUrl }),
...(channel.icon && { icon: channel.icon }),
...(channel.email && { email: channel.email }),
});
const result = await httpRequest(
`${server}/${encodeURIComponent(topic)}`,
{ method: "POST", headers, body: payload.text },
server,
{ method: "POST", headers, body },
timeoutMs,
);
return addErrorDetail(result);
+1 -1
View File
@@ -116,7 +116,7 @@ function describeConfig(
`配置: ${config.path}`,
`渠道: ${channel ? formatChannelLabel(channel, 0) : "ntfy(未配置)"}`,
`事件: ${(["idle", "interrupted", "needInput", "exit"] as const).map((key) => `${key}=${config.events[key] ? "on" : "off"}`).join(" ")}`,
`模式: ${config.modes.join(",")}`,
`模式: 全部${config.excludedModes.length > 0 ? `(排除: ${config.excludedModes.join(", ")})` : ""}`,
];
if (last && describeResults)
lines.push(
+4 -4
View File
@@ -1,8 +1,8 @@
{
"_说明": "pi-ntfy 只支持 NTFY。复制为 config.json 后按需修改。",
"_说明": "pi-ntfy 只支持 NTFY。默认所有工作模式(tui/json/rpc/print)都会推送;excludedModes 仅列出不需要推送的模式,例如 [\"json\"] 可让 pi --mode json -p 的子代理保持安静。复制为 config.json 后按需修改。",
"_占位符": "{{status}} {{reason}} {{text}} {{title}} {{event}} {{cwd}} {{project}} {{duration}} {{session}} {{sessionId}} {{model}} {{mode}} {{thinking}} {{task}} {{summary}} {{tools}} {{host}} {{time}} {{date}}",
"enabled": true,
"modes": ["tui"],
"excludedModes": [],
"timeoutMs": 5000,
"shutdownTimeoutMs": 2000,
"dedupeMs": 3000,
@@ -23,8 +23,8 @@
"type": "ntfy",
"name": "手机 ntfy",
"enabled": true,
"topic": "$NTFY_TOPIC",
"server": "$NTFY_HOST",
"topic": "Agent",
"server": "https://n.ipao.vip",
"token": "$NTFY_TOKEN",
"priority": "default",
"tags": "pi,computer"
+53 -31
View File
@@ -9,35 +9,21 @@ import {
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ChannelConfig, NotifyConfig, NtfyChannel } from "./types.ts";
import { ALL_MODES, type ChannelConfig, type NotifyConfig, type NtfyChannel } from "./types.ts";
function resolveExtensionDir(): string {
try {
return dirname(fileURLToPath(import.meta.url));
} catch {
return join(homedir(), ".pi", "agent", "extensions", "pi-ntfy");
}
}
export const EXTENSION_DIR = resolveExtensionDir();
const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-ntfy.json");
export function configPath(): string {
const override =
process.env.PI_NTFY_CONFIG?.trim() ||
process.env.PI_AGENT_PUSH_CONFIG?.trim() ||
process.env.PI_PUSH_CONFIG?.trim();
return override ? override : join(EXTENSION_DIR, "config.json");
return CONFIG_PATH;
}
export function logPath(): string {
// Next to the config file, so PI_AGENT_PUSH_CONFIG keeps everything together.
return join(dirname(configPath()), "push.log");
return join(dirname(configPath()), "pi-ntfy.log");
}
export const KNOWN_TYPES = ["ntfy"] as const;
export type KnownChannelType = (typeof KNOWN_TYPES)[number];
/** Whole-value reference: "$BARK_KEY" */
/** Whole-value reference: "$NTFY_TOKEN" */
const ENV_REF = /^\$([A-Za-z_][A-Za-z0-9_]*)$/;
/** Embedded reference: "Bearer ${MY_TOKEN}" */
const ENV_INTERPOLATION = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
@@ -89,20 +75,44 @@ function str(value: unknown, fallback: string): string {
return typeof value === "string" && value.length > 0 ? value : fallback;
}
function strArray(value: unknown, fallback: string[]): string[] {
if (!Array.isArray(value)) return fallback;
const items = value.filter(
function strArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter(
(v): v is string => typeof v === "string" && v.length > 0,
);
return items.length > 0 ? items : fallback;
}
/** Validate excludedModes: known modes only, deduped, with warnings. */
function excludedModes(
value: unknown,
fallback: string[],
warnings: string[],
): string[] {
if (value === undefined) return fallback;
if (!Array.isArray(value)) {
warnings.push('"excludedModes" 必须是数组,已忽略');
return [];
}
const out: string[] = [];
for (const item of value) {
if (typeof item !== "string" || item.length === 0) continue;
if (!(ALL_MODES as readonly string[]).includes(item)) {
warnings.push(
`excludedModes 中的 "${item}" 不是有效模式(可选: ${ALL_MODES.join("/")}),已忽略`,
);
continue;
}
if (!out.includes(item)) out.push(item);
}
return out;
}
export function defaultConfig(path: string): NotifyConfig {
return {
enabled: true,
// Only the interactive TUI by default: pi subagents run `pi --mode json -p`,
// and pushing for every one of those would be pure noise.
modes: ["tui"],
// All working modes (tui/json/rpc/print) push by default. To quiet
// `pi --mode json -p` subagents, set "excludedModes": ["json"].
excludedModes: [],
timeoutMs: 5000,
shutdownTimeoutMs: 2000,
dedupeMs: 3000,
@@ -182,7 +192,15 @@ export function loadConfig(): NotifyConfig {
}
config.enabled = bool(parsed.enabled, config.enabled);
config.modes = strArray(parsed.modes, config.modes);
config.excludedModes = excludedModes(
parsed.excludedModes,
config.excludedModes,
config.warnings,
);
if (parsed.modes !== undefined)
config.warnings.push(
'"modes" 已废弃,请改用 "excludedModes"(仅列出不需要推送的模式)',
);
config.timeoutMs = num(parsed.timeoutMs, config.timeoutMs, 200, 60_000);
config.shutdownTimeoutMs = num(
parsed.shutdownTimeoutMs,
@@ -200,7 +218,7 @@ export function loadConfig(): NotifyConfig {
config.maxTextChars = num(parsed.maxTextChars, config.maxTextChars, 20, 4000);
config.titleTemplate = str(parsed.titleTemplate, config.titleTemplate);
config.template = str(parsed.template, config.template);
config.needInputTools = strArray(parsed.needInputTools, config.needInputTools);
config.needInputTools = strArray(parsed.needInputTools);
config.debug = bool(parsed.debug, config.debug);
if (parsed.events && typeof parsed.events === "object") {
@@ -254,7 +272,7 @@ export const CHANNEL_SETTABLE_FIELDS: Record<string, string[]> = {
export interface RawConfigFile {
enabled?: boolean;
modes?: string[];
excludedModes?: string[];
timeoutMs?: number;
shutdownTimeoutMs?: number;
dedupeMs?: number;
@@ -282,8 +300,12 @@ export function loadRawConfig(): {
exists: false,
data: {
enabled: true,
modes: ["tui"],
events: { idle: true, interrupted: true, needInput: true, exit: false },
events: {
idle: true,
interrupted: true,
needInput: true,
exit: false,
},
channels: [],
},
};
+1 -7
View File
@@ -1,10 +1,4 @@
/**
* pi-agent-push — HTTP transport.
*
* Every request carries its own `AbortSignal.timeout`. It must never use the
* agent's `ctx.signal`: on the "interrupted" path that signal is already
* aborted, so the notification about the cancellation would cancel itself.
*/
/** pi-ntfy HTTP transport. */
import type { ChannelResult } from "./types.ts";
+2 -2
View File
@@ -74,8 +74,8 @@ export default function piNtfy(pi: ExtensionAPI) {
if (!runtimeEnabled) return undefined;
const config = loadConfig();
if (!config.enabled || !config.exists) return undefined;
// Subagents run `pi --mode json -p`; pushing for each of those is noise.
if (!config.modes.includes(mode)) return undefined;
// All modes push unless listed in excludedModes (e.g. subagents on json).
if (config.excludedModes.includes(mode)) return undefined;
return config;
}
+1 -5
View File
@@ -1,6 +1,6 @@
{
"name": "pi-ntfy",
"version": "1.0.0",
"version": "1.1.0",
"description": "Project and session-aware NTFY notifications for the pi coding agent.",
"type": "module",
"license": "MIT",
@@ -35,10 +35,6 @@
"LICENSE",
"DESIGN.md"
],
"scripts": {
"test": "node test/run.ts",
"prepublishOnly": "node test/run.ts"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
},
+1 -8
View File
@@ -1,11 +1,4 @@
/**
* pi-agent-push — `{{placeholder}}` substitution.
*
* Escaping matters: a raw string template that lands inside JSON must have its
* values JSON-escaped, otherwise a quote or newline in an error message
* produces an invalid request body. Object templates avoid the problem
* entirely (substitute first, stringify after) and are the recommended form.
*/
/** pi-ntfy template rendering. */
const PLACEHOLDER = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
+10 -1
View File
@@ -34,7 +34,8 @@ export type ChannelConfig = NtfyChannel;
export interface NotifyConfig {
enabled: boolean;
modes: string[];
/** Modes that must stay quiet; all others push. Empty array = push everywhere. */
excludedModes: string[];
timeoutMs: number;
shutdownTimeoutMs: number;
dedupeMs: number;
@@ -51,6 +52,14 @@ export interface NotifyConfig {
exists: boolean;
}
export const ALL_MODES = [
"tui",
"json",
"rpc",
"print",
] as const;
export type KnownMode = (typeof ALL_MODES)[number];
export interface NotifyPayload {
event: NotifyEvent;
status: string;