@gkzhb/pi-ntfy 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # @gkzhb/pi-ntfy
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi 人工 TUI 会话本轮完成后发送 ntfy 通知。独立 Pi 包,不是 Herdr manifest 插件;无需安装旧的 `plugins/agent-ntfy`。不注册 agent 工具、不改变提示词、零运行时 npm 依赖。
4
+
5
+ ## 安装
6
+
7
+ 要求 Node.js >= 22.18.0、支持 `agent_before_settle` / `agent_settled` 和 `ctx.mode` 的 upstream Pi(以 1.3.0 验证),无需 patch Pi。发布到 npm 后可安装:
8
+
9
+ ```sh
10
+ pi install npm:@gkzhb/pi-ntfy
11
+ ```
12
+
13
+ 发布前或本地开发,可从本仓库根目录使用:
14
+
15
+ ```sh
16
+ pi install ./packages/pi-ntfy
17
+ # 或只在这一次 Pi 进程中加载,不写入 Pi settings:
18
+ pi -e ./packages/pi-ntfy
19
+ ```
20
+
21
+ 安装后重启 Pi。此包不自动创建、迁移或覆盖任何配置。
22
+
23
+ ## 配置与第一次通知
24
+
25
+ 自行创建 `~/.pi/agent/ntfy.json`(已存在时编辑,不要覆盖):
26
+
27
+ ```json
28
+ {
29
+ "server": "https://ntfy.example.com",
30
+ "topic": "your-private-topic",
31
+ "token": "YOUR_NTFY_TOKEN",
32
+ "timeoutMs": 10000
33
+ }
34
+ ```
35
+
36
+ - `server`:HTTP(S) 服务根 URL,默认 `https://ntfy.sh`;不支持路径、查询、片段或 URL 内凭据。
37
+ - `topic`:必填,1–64 个英文字母、数字、下划线或连字符。
38
+ - `token`:可选 Bearer token;非空无空白 ASCII 字符串,配置时必须使用 HTTPS。
39
+ - `timeoutMs`:1–60000 整数,默认 10000;用于 HTTP 请求的有限超时。不重试,避免重复发送。
40
+
41
+ 这些字段与仓库旧 Herdr ntfy 插件对应,但配置文件相互独立。仅需复制所需字段;不要把凭据放进源码或公开 topic。Unix 下将文件权限设为 `0600`,父目录限制为当前用户可访问,Windows 下限制文件 ACL。
42
+
43
+ 设置 `PI_CODING_AGENT_DIR` 时,改读该隔离 agent 目录下的 `ntfy.json`,与 Pi 自己的配置隔离方式一致。每次通知重新读取文件,修改立即生效。缺失配置时静默禁用。无效配置或投递失败仅在 TUI 显示固定、不含服务详情的 warning,连续失败不会刷屏;成功后重置 warning。
44
+
45
+ 在订阅端订阅对应 topic,在人工主 TUI 中发送一个普通请求并等待结束。正文以“Pi 本轮已结束,等待输入。”开头,标题为“Pi 已完成”。仅追加下述身份元信息,不发送对话文本、工具结果、工作目录或主机名。中文 title 放在 UTF-8 JSON 中,避免 HTTP header 编码问题;priority 为 3,tags 为 `white_check_mark`。POST 至服务根地址;禁止自动跟随重定向,避免凭据转发。
46
+
47
+ ## 通知元信息
48
+
49
+ 元信息放在 ntfy 支持的 `message` 文本内,每项一行,不添加自定义 ntfy JSON 字段:
50
+
51
+ - `Pi session`:当前 `ctx.sessionManager.getSessionId()` 的原生 Pi 会话 ID,完整保留便于区分多个 Pi / resume;不是 session 文件路径。
52
+ - `Terminal pane`:当前进程 `HERDR_PANE_ID`,仅接受合法窗格 ID。
53
+ - `tuios session`:当前进程 `TUIOS_SESSION` 名称,与 Pi session 不同。
54
+ - `Terminal title`:通过 Herdr 兼容 CLI 查询**当前窗格**的标题;`Terminal tab`:该窗格当前 Tab ID 与可用的 label(tuios 中对应 workspace)。不使用聚焦窗格,不枚举其他会话,忽略可能因窗格移动而过时的 `HERDR_TAB_ID`。
55
+
56
+ 使用 tuios 当前支持的 Herdr 兼容 `pane get` / `tab get`(`HERDR_BIN_PATH`,缺省 `herdr`),每次查询最多 750ms、32KiB 输出,不经过 shell。无 tuios/Herdr、CLI 失败、拒绝读取或不匹配 ID 时省略相关信息,仍通知;无终端环境时仅有 Pi session。标题和 label 去控制字符并截断为 120 字符,但可能包含用户自定的敏感文本:启用即允许将这些标签发往所配置的 ntfy topic,勿在终端标题/标签中放凭据或秘密。不会读取任何 pane 对话/agent summary/session 文件,也不会发送整个环境或 CLI 响应。
57
+
58
+ ## 哪些会话会通知
59
+
60
+ 1. 只允许 `ctx.mode === "tui"`,RPC / JSON / print 不通知。`hasUI` 不是主子判断依据,真正的 worker 也可能是 TUI。
61
+ 2. **每次拟通知时**同步查询 `pi.events.emit("shepherdr:worker-role:v1", { sessionManager: ctx.sessionManager, reply })`。任一 `true` 响应即跳过;使用当前 session manager,不缓存角色、不依赖扩展加载顺序。
62
+ 3. `false` 仅表示 Shepherdr 未将当前会话标记为 worker;无响应是 unknown,**不是已确认主 agent**。为支持独立 Pi,unknown TUI 默认允许通知。
63
+
64
+ 与支持该协议的 Shepherdr(0.2.23+)一起使用时,启动标记 worker、嵌套 worker、Pulse worker,以及按原生 session ID 保留 worker 标记的 resume 都会被过滤,不需要本包维护另一套身份状态。
65
+
66
+ **边界:这不是通用 main-only 保证。** 不支持该协议的第三方启动器若用 TUI 启动子 agent,本包无法识别;独立启动后被 `assign` / `attach` 的会话不会自动变成 worker;不带 worker flag 打开 worker 的新 fork 也不自动继承旧 session ID 的标记。不要用这套默认策略声称“所有启动器下只通知唯一 root”。需要严格主会话保证时,只在受控的人工 TUI / 兼容 Shepherdr 启动路径加载本包,不要在未知第三方 TUI worker 配置中启用。
67
+
68
+ ## 完成、取消与失败
69
+
70
+ 仅在最终边界 `agent_settled` 发送通知。`agent_end` 只观察本次 run 的最后一条 assistant 消息,`agent_before_settle` 观察最终 `outcome`;必须同时满足 `completed`、`aborted === false`、消息 `stopReason === "stop"`、存在非空文字且不含待执行 tool call 才通知。
71
+
72
+ LLM API 错误即使带有部分文字也不通知;空白、仅思考、达到 token 上限的截断回复和仅工具调用也不算完成。`pi-retry` 可能在一次结算后另开隐藏 run,继续重试错误、空回复或截断输出;这些中间结算保持静默,直到正常停止且有有效文字才通知。重试耗尽或被取消时不发送失败通知。每个新 run 重置判定,不借用之前成功回复;不依赖重试插件的事件发射顺序,也不通过冷却时间压制后续正常通知。
73
+
74
+ 这是 **Pi 的本轮完成**,不是进程退出、整个项目完成,也不保证模型答复或工具操作在业务上成功:可恢复的工具错误后正常答复仍可能是 `completed`。准备阶段失败或普通 slash 命令不一定产生完成事件。处理器被 Pi 等待,等待时间受有限请求超时及最多两次各 750ms 的可选终端查询影响;失败不让 Pi 崩溃、不发起新模型 turn。
75
+
76
+ ## 可选后续:ntfy → Pi(尚未实现或启用)
77
+
78
+ ntfy 具备订阅 API,但能读取出站通知不应自动得到操作 Pi 的权限。建议未来单独设计 opt-in 入站服务,不订阅出站 topic:
79
+
80
+ - 独立、私有、最小权限的入站 topic / token,路由显式绑定 Pi session ID,生命周期结束即撤销;不要把 token 或可写入站地址附在通知中。
81
+ - 用签名、发件人授权、会话 nonce 与短有效期认证消息,不能仅信任 topic 可读性、文本声称的身份或公开的会话 ID。
82
+ - 按消息 ID + nonce 去重、持久记录已处理消息并限制时间窗口,避免重放;过滤自发消息并防止完成通知回环,限制大小与频率。
83
+ - 先在人类 UI 中审批入站内容,不自动将其作为用户批准、slash 命令或工具调用;明确区分 idle 新 prompt、busy 排队 follow-up 与 steering,缺省拒绝 busy 注入。
84
+ - 将远程文本当不可信输入;任何工具执行仍受本地权限/审批约束,ntfy 读取者不获得 shell、凭据或 approval 权限。明确断网、重连、重放及 agent 恢复时的行为后再实现。
85
+
86
+ 当前包只有出站 POST,不订阅服务、不注册远程控制工具,未在正式会话上线远程输入。
87
+
88
+ ## 开发检查与发布
89
+
90
+ 在仓库根目录执行 `pnpm install --frozen-lockfile`、`pnpm run check`。Pi 为 type-only peer(`*`),开发期固定 1.3.0 验证;不捆绑或安装第二份运行时。仅新包启用 `skipLibCheck`,避免 Pi 1.3.0 依赖的 `.d.ts` 中 JSON 导入等声明兼容问题;本包源码仍执行仓库严格 TS 检查,不修改 upstream。`files` 白名单仅发布 `src/` 和 README(加 npm 自动包含的 package.json / LICENSE),无配置、凭据、规划或探针。维护者审查后可在包目录用 `pnpm pack` / `pnpm publish --access public` 发布;仓库根包及旧 Herdr 插件仍为 private。本次实现不执行发布。
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@gkzhb/pi-ntfy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Private-by-default ntfy completion notifications for human Pi TUI sessions.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.18.0"
8
+ },
9
+ "keywords": [
10
+ "pi-package",
11
+ "ntfy"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/gkzhb/herdr-plugins.git",
16
+ "directory": "packages/pi-ntfy"
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "README.md"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/index.ts"
25
+ ]
26
+ },
27
+ "scripts": {
28
+ "typecheck": "tsc -p tsconfig.json"
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*"
32
+ },
33
+ "devDependencies": {
34
+ "@earendil-works/pi-coding-agent": "1.3.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
package/src/config.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface Config {
6
+ server: string;
7
+ topic: string;
8
+ token?: string;
9
+ timeoutMs: number;
10
+ }
11
+
12
+ export function parseConfig(value: unknown): Config {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ throw new Error("Invalid ntfy configuration");
15
+ }
16
+ const raw = value as Record<string, unknown>;
17
+ const server = new URL(typeof raw.server === "string" ? raw.server : "https://ntfy.sh");
18
+ if (
19
+ (raw.server !== undefined && typeof raw.server !== "string") ||
20
+ !["https:", "http:"].includes(server.protocol) ||
21
+ server.username || server.password || server.search || server.hash || server.pathname !== "/" ||
22
+ typeof raw.topic !== "string" || raw.topic.match(/^[A-Za-z0-9_-]{1,64}$/)?.[0] !== raw.topic
23
+ ) throw new Error("Invalid ntfy configuration");
24
+ const config: Config = { server: server.href, topic: raw.topic, timeoutMs: 10_000 };
25
+ if (raw.token !== undefined) {
26
+ if (typeof raw.token !== "string" || raw.token.match(/^[\x21-\x7e]+$/)?.[0] !== raw.token || server.protocol !== "https:") {
27
+ throw new Error("Invalid ntfy configuration");
28
+ }
29
+ config.token = raw.token;
30
+ }
31
+ if (raw.timeoutMs !== undefined) {
32
+ if (!Number.isInteger(raw.timeoutMs) || typeof raw.timeoutMs !== "number" || raw.timeoutMs < 1 || raw.timeoutMs > 60_000) {
33
+ throw new Error("Invalid ntfy configuration");
34
+ }
35
+ config.timeoutMs = raw.timeoutMs;
36
+ }
37
+ return config;
38
+ }
39
+
40
+ // Read on demand, so editing this file does not require an extension reload.
41
+ export async function loadConfig(): Promise<Config | undefined> {
42
+ const directory = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
43
+ let raw: string;
44
+ try {
45
+ raw = await readFile(join(directory, "ntfy.json"), "utf8");
46
+ } catch (error) {
47
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
48
+ throw new Error("Cannot read ntfy configuration");
49
+ }
50
+ try {
51
+ return parseConfig(JSON.parse(raw));
52
+ } catch {
53
+ // Neither parser errors nor URL errors may escape with configuration bytes.
54
+ throw new Error("Invalid ntfy configuration");
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig } from "./config.ts";
3
+ import { publish } from "./publish.ts";
4
+ import { metadata } from "./metadata.ts";
5
+
6
+ function isWorker(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
7
+ let worker = false;
8
+ pi.events.emit("shepherdr:worker-role:v1", {
9
+ sessionManager: ctx.sessionManager,
10
+ // A positive worker response wins; no response is unknown, not proof of main.
11
+ reply: (value: boolean) => { if (value === true) worker = true; },
12
+ });
13
+ return worker;
14
+ }
15
+
16
+ export default function ntfy(pi: ExtensionAPI): void {
17
+ let outcome: "completed" | "aborted" | "error" | undefined;
18
+ let hasFinalAnswer = false;
19
+ let warned = false;
20
+
21
+ pi.on("agent_start", () => { outcome = undefined; hasFinalAnswer = false; });
22
+ // A completed boundary can also mean length, toolUse or an empty/thinking-only
23
+ // stop. pi-retry may continue those in another run after settlement.
24
+ pi.on("agent_end", (event) => {
25
+ const last = event.messages.findLast((message) => message.role === "assistant");
26
+ hasFinalAnswer = last?.role === "assistant" && last.stopReason === "stop" &&
27
+ last.content.some((part) => part.type === "text" && part.text.trim().length > 0) &&
28
+ !last.content.some((part) => part.type === "toolCall");
29
+ });
30
+ // This is observation only: never request another turn or alter the prompt.
31
+ pi.on("agent_before_settle", (event) => { outcome = event.outcome; });
32
+ pi.on("agent_settled", async (event, ctx) => {
33
+ const completed = outcome === "completed" && hasFinalAnswer;
34
+ outcome = undefined;
35
+ hasFinalAnswer = false;
36
+ if (event.aborted || !completed || ctx.mode !== "tui") return;
37
+ try {
38
+ // Query here, not at factory/session_start: worker identity can change.
39
+ if (isWorker(pi, ctx)) return;
40
+ const config = await loadConfig();
41
+ if (!config) return;
42
+ await publish(config, await metadata(ctx));
43
+ warned = false;
44
+ } catch {
45
+ // Raw errors may contain endpoint, response or credentials. Never forward.
46
+ if (!warned) {
47
+ warned = true;
48
+ ctx.ui.notify("pi-ntfy: notification failed; check ntfy.json and service availability.", "warning");
49
+ }
50
+ }
51
+ });
52
+ }
@@ -0,0 +1,57 @@
1
+ import { execFile } from "node:child_process";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ function record(value: unknown): value is Record<string, unknown> {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ function label(value: unknown): string | undefined {
9
+ if (typeof value !== "string") return;
10
+ const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim();
11
+ return text ? [...text].slice(0, 120).join("") : undefined;
12
+ }
13
+
14
+ async function query(args: string[]): Promise<unknown> {
15
+ return new Promise((resolve) => {
16
+ execFile(process.env.HERDR_BIN_PATH || "herdr", args, {
17
+ encoding: "utf8", timeout: 750, killSignal: "SIGKILL", maxBuffer: 32 * 1024, windowsHide: true,
18
+ }, (error, stdout) => {
19
+ if (error) { resolve(undefined); return; }
20
+ try { resolve(JSON.parse(stdout)); } catch { resolve(undefined); }
21
+ });
22
+ });
23
+ }
24
+
25
+ // Only our environment and our pane/tab are consulted, never focused-pane/list APIs.
26
+ export async function metadata(ctx: ExtensionContext): Promise<string[]> {
27
+ const lines: string[] = [];
28
+ const sessionId = ctx.sessionManager.getSessionId();
29
+ // Keep the native ID, including safe CLI-defined IDs, never a session file path.
30
+ if (sessionId.match(/^[a-zA-Z0-9_.-]{1,128}$/)?.[0] === sessionId) lines.push(`Pi session: ${sessionId}`);
31
+ const paneId = process.env.HERDR_PANE_ID;
32
+ if (!paneId || paneId.match(/^w[A-Za-z0-9]+:p[A-Za-z0-9]+$/)?.[0] !== paneId) return lines;
33
+ lines.push(`Terminal pane: ${paneId}`);
34
+ const tuiosSession = label(process.env.TUIOS_SESSION);
35
+ if (tuiosSession) lines.push(`tuios session: ${tuiosSession}`);
36
+ try {
37
+ const response = await query(["pane", "get", paneId]);
38
+ if (!record(response) || !record(response.result)) return lines;
39
+ const pane = response.result.pane;
40
+ if (!record(pane) || pane.pane_id !== paneId) return lines;
41
+ const title = label(pane.terminal_title_stripped ?? pane.terminal_title);
42
+ if (title) lines.push(`Terminal title: ${title}`);
43
+ const tabId = pane.tab_id;
44
+ if (typeof tabId !== "string" || tabId.match(/^w[A-Za-z0-9]+:t[A-Za-z0-9]+$/)?.[0] !== tabId) return lines;
45
+ // Do not fall back to inherited HERDR_TAB_ID: panes can move.
46
+ const tabResponse = await query(["tab", "get", tabId]);
47
+ let tabLabel: string | undefined;
48
+ if (record(tabResponse) && record(tabResponse.result)) {
49
+ const tab = tabResponse.result.tab;
50
+ if (record(tab) && tab.tab_id === tabId) tabLabel = label(tab.label);
51
+ }
52
+ lines.push(`Terminal tab: ${tabId}${tabLabel ? ` (${tabLabel})` : ""}`);
53
+ } catch {
54
+ // Optional metadata must not prevent completion delivery.
55
+ }
56
+ return lines;
57
+ }
package/src/publish.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { Config } from "./config.ts";
2
+
3
+ export async function publish(config: Config, details: string[] = []): Promise<void> {
4
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
5
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
6
+ let response: Response;
7
+ try {
8
+ response = await fetch(config.server, {
9
+ method: "POST",
10
+ headers,
11
+ redirect: "error",
12
+ signal: AbortSignal.timeout(config.timeoutMs),
13
+ body: JSON.stringify({
14
+ topic: config.topic,
15
+ title: "Pi 已完成",
16
+ message: ["Pi 本轮已结束,等待输入。", ...details].join("\n"),
17
+ tags: ["white_check_mark"],
18
+ priority: 3,
19
+ }),
20
+ });
21
+ } catch {
22
+ throw new Error("ntfy request failed (network, redirect or timeout)");
23
+ }
24
+ // No response text is logged or retained, even for authentication failures.
25
+ await response.body?.cancel().catch(() => {});
26
+ if (!response.ok) throw new Error(`ntfy publish failed (HTTP ${response.status})`);
27
+ }