@gkzhb/pi-ntfy 0.0.0-stage → 0.1.1
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 +92 -2
- package/package.json +37 -4
- package/src/config.ts +56 -0
- package/src/index.ts +56 -0
- package/src/metadata.ts +53 -0
- package/src/publish.ts +29 -0
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @gkzhb/pi-ntfy
|
|
2
2
|
|
|
3
|
-
|
|
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 中发送一个普通请求并等待结束。格式与本仓库 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 文件路径。
|
|
48
|
+
|
|
49
|
+
## 通知元信息
|
|
50
|
+
|
|
51
|
+
元信息放在 ntfy 支持的 `message` 文本内,每项一行,不添加自定义 ntfy JSON 字段:
|
|
52
|
+
|
|
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;不是文件路径。
|
|
58
|
+
|
|
59
|
+
只调用原生 `tuios` CLI,不读取 `HERDR_*` 字段或调用 Herdr 兼容接口。每次查询最多 750ms、32KiB 输出,不经过 shell。CLI 不存在、查询失败、拒绝读取或返回不匹配 UUID 时省略可选信息,仍通知;无 tuios 环境时保留 Pi 会话、状态和摘要。工作区列表仅用于匹配当前窗格的工作区名称,不采集其他窗格信息。标题与标签去终端转义、控制字符并截断为 120 字符,不发送完整 CLI 响应或整个环境。
|
|
60
|
+
|
|
61
|
+
## 哪些会话会通知
|
|
62
|
+
|
|
63
|
+
1. 只允许 `ctx.mode === "tui"`,RPC / JSON / print 不通知。`hasUI` 不是主子判断依据,真正的 worker 也可能是 TUI。
|
|
64
|
+
2. **每次拟通知时**同步查询 `pi.events.emit("shepherdr:worker-role:v1", { sessionManager: ctx.sessionManager, reply })`。任一 `true` 响应即跳过;使用当前 session manager,不缓存角色、不依赖扩展加载顺序。
|
|
65
|
+
3. `false` 仅表示 Shepherdr 未将当前会话标记为 worker;无响应是 unknown,**不是已确认主 agent**。为支持独立 Pi,unknown TUI 默认允许通知。
|
|
66
|
+
|
|
67
|
+
与支持该协议的 Shepherdr(0.2.23+)一起使用时,启动标记 worker、嵌套 worker、Pulse worker,以及按原生 session ID 保留 worker 标记的 resume 都会被过滤,不需要本包维护另一套身份状态。
|
|
68
|
+
|
|
69
|
+
**边界:这不是通用 main-only 保证。** 不支持该协议的第三方启动器若用 TUI 启动子 agent,本包无法识别;独立启动后被 `assign` / `attach` 的会话不会自动变成 worker;不带 worker flag 打开 worker 的新 fork 也不自动继承旧 session ID 的标记。不要用这套默认策略声称“所有启动器下只通知唯一 root”。需要严格主会话保证时,只在受控的人工 TUI / 兼容 Shepherdr 启动路径加载本包,不要在未知第三方 TUI worker 配置中启用。
|
|
70
|
+
|
|
71
|
+
## 完成、取消与失败
|
|
72
|
+
|
|
73
|
+
仅在最终边界 `agent_settled` 发送通知。`agent_end` 只观察本次 run 的最后一条 assistant 消息,`agent_before_settle` 观察最终 `outcome`;必须同时满足 `completed`、`aborted === false`、消息 `stopReason === "stop"`、存在非空文字且不含待执行 tool call 才通知。
|
|
74
|
+
|
|
75
|
+
LLM API 错误即使带有部分文字也不通知;空白、仅思考、达到 token 上限的截断回复和仅工具调用也不算完成。`pi-retry` 可能在一次结算后另开隐藏 run,继续重试错误、空回复或截断输出;这些中间结算保持静默,直到正常停止且有有效文字才通知。重试耗尽或被取消时不发送失败通知。每个新 run 重置判定,不借用之前成功回复;不依赖重试插件的事件发射顺序,也不通过冷却时间压制后续正常通知。
|
|
76
|
+
|
|
77
|
+
这是 **Pi 的本轮完成**,不是进程退出、整个项目完成,也不保证模型答复或工具操作在业务上成功:可恢复的工具错误后正常答复仍可能是 `completed`。准备阶段失败或普通 slash 命令不一定产生完成事件。处理器被 Pi 等待,等待时间受有限请求超时及最多两次各 750ms 的可选终端查询影响;失败不让 Pi 崩溃、不发起新模型 turn。
|
|
78
|
+
|
|
79
|
+
## 可选后续:ntfy → Pi(尚未实现或启用)
|
|
80
|
+
|
|
81
|
+
ntfy 具备订阅 API,但能读取出站通知不应自动得到操作 Pi 的权限。建议未来单独设计 opt-in 入站服务,不订阅出站 topic:
|
|
82
|
+
|
|
83
|
+
- 独立、私有、最小权限的入站 topic / token,路由显式绑定 Pi session ID,生命周期结束即撤销;不要把 token 或可写入站地址附在通知中。
|
|
84
|
+
- 用签名、发件人授权、会话 nonce 与短有效期认证消息,不能仅信任 topic 可读性、文本声称的身份或公开的会话 ID。
|
|
85
|
+
- 按消息 ID + nonce 去重、持久记录已处理消息并限制时间窗口,避免重放;过滤自发消息并防止完成通知回环,限制大小与频率。
|
|
86
|
+
- 先在人类 UI 中审批入站内容,不自动将其作为用户批准、slash 命令或工具调用;明确区分 idle 新 prompt、busy 排队 follow-up 与 steering,缺省拒绝 busy 注入。
|
|
87
|
+
- 将远程文本当不可信输入;任何工具执行仍受本地权限/审批约束,ntfy 读取者不获得 shell、凭据或 approval 权限。明确断网、重连、重放及 agent 恢复时的行为后再实现。
|
|
88
|
+
|
|
89
|
+
当前包只有出站 POST,不订阅服务、不注册远程控制工具,未在正式会话上线远程输入。
|
|
90
|
+
|
|
91
|
+
## 开发检查与发布
|
|
92
|
+
|
|
93
|
+
在仓库根目录执行 `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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "ntfy completion notifications with reply summaries and tuios metadata 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,56 @@
|
|
|
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 finalSummary: string | undefined;
|
|
19
|
+
let warned = false;
|
|
20
|
+
|
|
21
|
+
pi.on("agent_start", () => { outcome = undefined; finalSummary = undefined; });
|
|
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
|
+
finalSummary = undefined;
|
|
27
|
+
if (last?.role !== "assistant" || last.stopReason !== "stop" ||
|
|
28
|
+
last.content.some((part) => part.type === "toolCall")) return;
|
|
29
|
+
const text = last.content.filter((part) => part.type === "text")
|
|
30
|
+
.map((part) => part.text).join("\n").replace(/\r\n?/g, "\n")
|
|
31
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "").trim();
|
|
32
|
+
if (text) finalSummary = [...text].slice(0, 300).join("").trimEnd();
|
|
33
|
+
});
|
|
34
|
+
// This is observation only: never request another turn or alter the prompt.
|
|
35
|
+
pi.on("agent_before_settle", (event) => { outcome = event.outcome; });
|
|
36
|
+
pi.on("agent_settled", async (event, ctx) => {
|
|
37
|
+
const summary = outcome === "completed" ? finalSummary : undefined;
|
|
38
|
+
outcome = undefined;
|
|
39
|
+
finalSummary = undefined;
|
|
40
|
+
if (event.aborted || !summary || ctx.mode !== "tui") return;
|
|
41
|
+
try {
|
|
42
|
+
// Query here, not at factory/session_start: worker identity can change.
|
|
43
|
+
if (isWorker(pi, ctx)) return;
|
|
44
|
+
const config = await loadConfig();
|
|
45
|
+
if (!config) return;
|
|
46
|
+
await publish(config, [...await metadata(ctx), "状态:done", "", "最后回复:", summary]);
|
|
47
|
+
warned = false;
|
|
48
|
+
} catch {
|
|
49
|
+
// Raw errors may contain endpoint, response or credentials. Never forward.
|
|
50
|
+
if (!warned) {
|
|
51
|
+
warned = true;
|
|
52
|
+
ctx.ui.notify("pi-ntfy: notification failed; check ntfy.json and service availability.", "warning");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function label(value: unknown): string | undefined {
|
|
10
|
+
if (typeof value !== "string") return;
|
|
11
|
+
const text = stripVTControlCharacters(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim();
|
|
12
|
+
return text ? [...text].slice(0, 120).join("") : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function query(args: string[]): Promise<unknown> {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
execFile("tuios", args, {
|
|
18
|
+
encoding: "utf8", timeout: 750, killSignal: "SIGKILL", maxBuffer: 32 * 1024, windowsHide: true,
|
|
19
|
+
}, (error, stdout) => {
|
|
20
|
+
if (error) { resolve(undefined); return; }
|
|
21
|
+
try { resolve(JSON.parse(stdout)); } catch { resolve(undefined); }
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Native tuios identity only; never query the focused window or terminal text.
|
|
27
|
+
export async function metadata(ctx: ExtensionContext): Promise<string[]> {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
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
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.push(`tuios 窗格:${pane}`);
|
|
50
|
+
}
|
|
51
|
+
if (piSession) lines.push(piSession);
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
package/src/publish.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import type { Config } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
export async function publish(config: Config, details: string[] = []): Promise<void> {
|
|
5
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
6
|
+
if (config.token) headers.Authorization = `Bearer ${config.token}`;
|
|
7
|
+
let response: Response;
|
|
8
|
+
try {
|
|
9
|
+
response = await fetch(config.server, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers,
|
|
12
|
+
redirect: "error",
|
|
13
|
+
signal: AbortSignal.timeout(config.timeoutMs),
|
|
14
|
+
body: JSON.stringify({
|
|
15
|
+
topic: config.topic,
|
|
16
|
+
title: `[${hostname()}] pi 已完成本轮工作`,
|
|
17
|
+
message: details.join("\n"),
|
|
18
|
+
tags: ["white_check_mark"],
|
|
19
|
+
priority: 3,
|
|
20
|
+
markdown: true,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error("ntfy request failed (network, redirect or timeout)");
|
|
25
|
+
}
|
|
26
|
+
// No response text is logged or retained, even for authentication failures.
|
|
27
|
+
await response.body?.cancel().catch(() => {});
|
|
28
|
+
if (!response.ok) throw new Error(`ntfy publish failed (HTTP ${response.status})`);
|
|
29
|
+
}
|