@gkzhb/pi-ntfy 0.1.0 → 0.1.2

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
@@ -42,21 +42,26 @@ pi -e ./packages/pi-ntfy
42
42
 
43
43
  设置 `PI_CODING_AGENT_DIR` 时,改读该隔离 agent 目录下的 `ntfy.json`,与 Pi 自己的配置隔离方式一致。每次通知重新读取文件,修改立即生效。缺失配置时静默禁用。无效配置或投递失败仅在 TUI 显示固定、不含服务详情的 warning,连续失败不会刷屏;成功后重置 warning。
44
44
 
45
- 在订阅端订阅对应 topic,在人工主 TUI 中发送一个普通请求并等待结束。正文以“Pi 本轮已结束,等待输入。”开头,标题为“Pi 已完成”。仅追加下述身份元信息,不发送对话文本、工具结果、工作目录或主机名。中文 title 放在 UTF-8 JSON 中,避免 HTTP header 编码问题;priority 为 3,tags 为 `white_check_mark`。POST 至服务根地址;禁止自动跟随重定向,避免凭据转发。
45
+ 在订阅端订阅对应 topic,在人工主 TUI 中发送一个普通请求并等待结束。格式与本仓库 Herdr ntfy 插件对齐:标题为 `[主机名] pi 已完成本轮工作`,正文含会话位置、`状态:done` 及最多 300 个 Unicode 字符的“最后回复”摘要。摘要只取本轮最后一条正常停止的 assistant 文字,保留换行、清理控制字符,不读取其他会话或终端正文。中文 title 放在 UTF-8 JSON 中,避免 HTTP header 编码问题;priority 为 3,tags 为 `white_check_mark`,`markdown: true`。POST 至服务根地址;禁止自动跟随重定向,避免凭据转发。
46
+
47
+ **内容边界改变:启用通知即允许将主机名、终端标签和最后回复的前 300 字符发送到配置的 ntfy 服务。** 摘要可能包含敏感信息,请使用可信服务与私有 topic。不发送完整历史、思考过程、工具调用或工具结果,也不发送 session 文件路径。
46
48
 
47
49
  ## 通知元信息
48
50
 
49
51
  元信息放在 ntfy 支持的 `message` 文本内,每项一行,不添加自定义 ntfy JSON 字段:
50
52
 
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`。
53
+ - `会话`:`tuios get-window <当前 UUID> --session <当前会话> --json` 返回的 `display_name` 或 `title`。
54
+ - `tuios 会话`:当前进程 `TUIOS_SESSION`,与 Pi 会话不同。
55
+ - `tuios 工作区`:当前窗格实际所属的数字工作区及可用名称;名称通过 `tuios list-workspaces` 获取,不使用当前聚焦的工作区。
56
+ - `tuios 窗格`:当前进程 `TUIOS_PANE_ID` 的原生 UUID,不使用 Herdr 的兼容地址或 Tab。
57
+ - `Pi 会话`:当前 `ctx.sessionManager.getSessionId()`,完整保留便于区分多个 Pi / resume;不是文件路径。
55
58
 
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 响应。
59
+ 只调用原生 `tuios` CLI,不读取 `HERDR_*` 字段或调用 Herdr 兼容接口。每次查询最多 750ms、32KiB 输出,不经过 shell。CLI 不存在、查询失败、拒绝读取或返回不匹配 UUID 时省略可选信息,仍通知;无 tuios 环境时保留 Pi 会话、状态和摘要。工作区列表仅用于匹配当前窗格的工作区名称,不采集其他窗格信息。标题与标签去终端转义、控制字符并截断为 120 字符,不发送完整 CLI 响应或整个环境。
57
60
 
58
61
  ## 哪些会话会通知
59
62
 
63
+ 启动时若存在 `BOTMUX` 环境变量(包括空值),整个插件直接禁用,不注册事件处理器,也不读取配置或发送 ntfy 通知。botmux 场景由飞书发送通知,避免重复提醒。
64
+
60
65
  1. 只允许 `ctx.mode === "tui"`,RPC / JSON / print 不通知。`hasUI` 不是主子判断依据,真正的 worker 也可能是 TUI。
61
66
  2. **每次拟通知时**同步查询 `pi.events.emit("shepherdr:worker-role:v1", { sessionManager: ctx.sessionManager, reply })`。任一 `true` 响应即跳过;使用当前 session manager,不缓存角色、不依赖扩展加载顺序。
62
67
  3. `false` 仅表示 Shepherdr 未将当前会话标记为 worker;无响应是 unknown,**不是已确认主 agent**。为支持独立 Pi,unknown TUI 默认允许通知。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gkzhb/pi-ntfy",
3
- "version": "0.1.0",
4
- "description": "Private-by-default ntfy completion notifications for human Pi TUI sessions.",
3
+ "version": "0.1.2",
4
+ "description": "ntfy completion notifications with reply summaries and tuios metadata for human Pi TUI sessions.",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=22.18.0"
package/src/index.ts CHANGED
@@ -14,32 +14,39 @@ function isWorker(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
14
14
  }
15
15
 
16
16
  export default function ntfy(pi: ExtensionAPI): void {
17
+ // Botmux sends notifications through Feishu instead.
18
+ if (process.env.BOTMUX !== undefined) return;
19
+
17
20
  let outcome: "completed" | "aborted" | "error" | undefined;
18
- let hasFinalAnswer = false;
21
+ let finalSummary: string | undefined;
19
22
  let warned = false;
20
23
 
21
- pi.on("agent_start", () => { outcome = undefined; hasFinalAnswer = false; });
24
+ pi.on("agent_start", () => { outcome = undefined; finalSummary = undefined; });
22
25
  // A completed boundary can also mean length, toolUse or an empty/thinking-only
23
26
  // stop. pi-retry may continue those in another run after settlement.
24
27
  pi.on("agent_end", (event) => {
25
28
  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
+ finalSummary = undefined;
30
+ if (last?.role !== "assistant" || last.stopReason !== "stop" ||
31
+ last.content.some((part) => part.type === "toolCall")) return;
32
+ const text = last.content.filter((part) => part.type === "text")
33
+ .map((part) => part.text).join("\n").replace(/\r\n?/g, "\n")
34
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "").trim();
35
+ if (text) finalSummary = [...text].slice(0, 300).join("").trimEnd();
29
36
  });
30
37
  // This is observation only: never request another turn or alter the prompt.
31
38
  pi.on("agent_before_settle", (event) => { outcome = event.outcome; });
32
39
  pi.on("agent_settled", async (event, ctx) => {
33
- const completed = outcome === "completed" && hasFinalAnswer;
40
+ const summary = outcome === "completed" ? finalSummary : undefined;
34
41
  outcome = undefined;
35
- hasFinalAnswer = false;
36
- if (event.aborted || !completed || ctx.mode !== "tui") return;
42
+ finalSummary = undefined;
43
+ if (event.aborted || !summary || ctx.mode !== "tui") return;
37
44
  try {
38
45
  // Query here, not at factory/session_start: worker identity can change.
39
46
  if (isWorker(pi, ctx)) return;
40
47
  const config = await loadConfig();
41
48
  if (!config) return;
42
- await publish(config, await metadata(ctx));
49
+ await publish(config, [...await metadata(ctx), "状态:done", "", "最后回复:", summary]);
43
50
  warned = false;
44
51
  } catch {
45
52
  // Raw errors may contain endpoint, response or credentials. Never forward.
package/src/metadata.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { stripVTControlCharacters } from "node:util";
2
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
4
 
4
5
  function record(value: unknown): value is Record<string, unknown> {
@@ -7,13 +8,13 @@ function record(value: unknown): value is Record<string, unknown> {
7
8
 
8
9
  function label(value: unknown): string | undefined {
9
10
  if (typeof value !== "string") return;
10
- const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim();
11
+ const text = stripVTControlCharacters(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim();
11
12
  return text ? [...text].slice(0, 120).join("") : undefined;
12
13
  }
13
14
 
14
15
  async function query(args: string[]): Promise<unknown> {
15
16
  return new Promise((resolve) => {
16
- execFile(process.env.HERDR_BIN_PATH || "herdr", args, {
17
+ execFile("tuios", args, {
17
18
  encoding: "utf8", timeout: 750, killSignal: "SIGKILL", maxBuffer: 32 * 1024, windowsHide: true,
18
19
  }, (error, stdout) => {
19
20
  if (error) { resolve(undefined); return; }
@@ -22,36 +23,31 @@ async function query(args: string[]): Promise<unknown> {
22
23
  });
23
24
  }
24
25
 
25
- // Only our environment and our pane/tab are consulted, never focused-pane/list APIs.
26
+ // Native tuios identity only; never query the focused window or terminal text.
26
27
  export async function metadata(ctx: ExtensionContext): Promise<string[]> {
27
28
  const lines: string[] = [];
28
29
  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);
30
+ const piSession = sessionId.match(/^[a-zA-Z0-9_.-]{1,128}$/)?.[0] === sessionId
31
+ ? `Pi 会话:${sessionId}` : undefined;
32
+ const pane = process.env.TUIOS_PANE_ID;
33
+ const session = process.env.TUIOS_SESSION;
34
+ if (session && label(session)) lines.push(`tuios 会话:${label(session)}`);
35
+ if (pane && pane.match(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i)?.[0] === pane && session) {
36
+ const window = await query(["get-window", pane, "--session", session, "--json"]);
37
+ if (record(window) && window.success === true && window.id === pane) {
38
+ const title = label(window.display_name) ?? label(window.title);
39
+ if (title) lines.unshift(`会话:${title}`);
40
+ if (typeof window.workspace === "number" && Number.isSafeInteger(window.workspace) && window.workspace > 0) {
41
+ const response = await query(["list-workspaces", "--session", session, "--json"]);
42
+ const workspace = record(response) && response.success === true && Array.isArray(response.workspaces)
43
+ ? response.workspaces.find((item: unknown) => record(item) && item.workspace === window.workspace)
44
+ : undefined;
45
+ const name = record(workspace) ? label(workspace.name) : undefined;
46
+ lines.push(`tuios 工作区:${name ? `${name} (${window.workspace})` : window.workspace}`);
47
+ }
51
48
  }
52
- lines.push(`Terminal tab: ${tabId}${tabLabel ? ` (${tabLabel})` : ""}`);
53
- } catch {
54
- // Optional metadata must not prevent completion delivery.
49
+ lines.push(`tuios 窗格:${pane}`);
55
50
  }
51
+ if (piSession) lines.push(piSession);
56
52
  return lines;
57
53
  }
package/src/publish.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostname } from "node:os";
1
2
  import type { Config } from "./config.ts";
2
3
 
3
4
  export async function publish(config: Config, details: string[] = []): Promise<void> {
@@ -12,10 +13,11 @@ export async function publish(config: Config, details: string[] = []): Promise<v
12
13
  signal: AbortSignal.timeout(config.timeoutMs),
13
14
  body: JSON.stringify({
14
15
  topic: config.topic,
15
- title: "Pi 已完成",
16
- message: ["Pi 本轮已结束,等待输入。", ...details].join("\n"),
16
+ title: `[${hostname()}] pi 已完成本轮工作`,
17
+ message: details.join("\n"),
17
18
  tags: ["white_check_mark"],
18
19
  priority: 3,
20
+ markdown: true,
19
21
  }),
20
22
  });
21
23
  } catch {