diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d0394e..4d35cd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,5 @@ jobs: node-version: "22.19.0" - run: npm ci --ignore-scripts --no-audit --no-fund - run: npm run check + - name: Verify npm package contents + run: npm pack --dry-run diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2f6292..69f87b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,9 @@ jobs: - name: Check run: npm run check + - name: Verify npm package contents + run: npm pack --dry-run + - name: Read package version id: version run: node -e "console.log('version=' + require('./package.json').version)" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 822b2f7..ee38955 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage/ *.log .env pi-knock.local.json +.test-dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e83ad2..1adbf2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Added + +- Automatic notification delivery retries for transient failures. +- `/knock doctor` diagnostics with last-delivery status and attempt count. +- Notification privacy mode to hide prompt text from lock-screen notifications, with `project-only` as the safe default. +- Session names in notification titles when Pi sessions are named. +- Provider, notifier, and lifecycle test coverage. +- npm package-content verification in CI and release workflows. + +### Changed + +- Duplicate `agent_settled` events are ignored once a run has already settled. +- Notification setup now includes a dedicated preferences screen. +- Documentation files and the Chinese README are included in the npm package. + + ## [0.2.1] - 2026-10-03 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddb8bfe..99e1e89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,10 @@ Behavior changes should include tests. In particular, lifecycle changes must pre Notification delivery failures must never break the agent run. +Lifecycle changes should be covered in `test/lifecycle.test.ts`. Provider request changes belong in `test/channels.test.ts`, and retry behavior belongs in `test/notifier.test.ts`. + +Before releasing, `npm pack --dry-run` must show the README files and `docs/` content expected by links in the package documentation. + ## Pull requests Keep changes focused. Update CHANGELOG.md for user-visible changes and avoid committing credentials, provider tokens, private webhook URLs, or local pi-knock configuration. diff --git a/README.md b/README.md index aa8d506..5c4d163 100644 --- a/README.md +++ b/README.md @@ -2,76 +2,55 @@ English | [简体中文](./README.zh-CN.md) -**Notifications for Pi when a task finishes, fails, or needs your input.** +**Leave the terminal. Pi will knock when it needs you.** -pi-knock is a lightweight Pi extension that sends notifications to **Pushover**, **ntfy**, or a **webhook**, so you do not need to keep watching the terminal while Pi works. - -## Features - -- ✅ Notify when a conversation finishes -- ❓ Notify when Pi is waiting for input or confirmation -- ❌ Notify when a task fails -- ⏹️ Optional notification for aborted runs -- 📱 Pushover, ntfy, and generic webhooks -- ⌚ Apple Watch via iPhone notification mirroring -- 🔗 Optional session URL for Pi-Web -- 🔐 Separate config and credential storage - -## Install - -Recommended: +pi-knock sends remote notifications when a Pi task **finishes, fails, or needs your input**. Use Pushover for iPhone / Apple Watch, ntfy for hosted or self-hosted push, or a webhook for your own automation. ```bash pi install npm:@asigers/pi-knock ``` -Or install directly from GitHub: +## Why pi-knock -```bash -pi install git:github.com/Asigers/pi-knock -``` - -Then restart Pi or run: - -```text -/reload -``` - -Requires **Pi 0.87+**. +- **Actually waits for completion** — completion alerts are sent at `agent_settled`, after Pi has finished automatic retries and queued work. +- **Works away from your desk** — notifications can reach your phone or watch instead of only the local terminal. +- **Reliable delivery** — transient failures are retried automatically. +- **Lock-screen safe by default** — prompt text is hidden unless you opt in. +- **Session-aware** — named Pi sessions appear in notification titles. +- **No push server required** — pi-knock sends directly to your configured provider. ## Quick start -Run: +After installation, restart Pi or run `/reload`, then: ```text /knock setup ``` -Choose a provider and enter the required settings. pi-knock sends a test notification after setup. - -Useful commands: +Choose a provider and enter the required settings. pi-knock immediately sends a test notification. | Command | Description | | --- | --- | -| `/knock setup` | Configure a notification provider | +| `/knock setup` | Configure providers and notification preferences | | `/knock status` | Show current configuration | -| `/knock test` | Send a test notification | +| `/knock test` | Send a live test notification | +| `/knock doctor` | Show provider and last-delivery diagnostics | + +Requires **Pi 0.87+**. ## Providers | Provider | Best for | | --- | --- | | **Pushover** | iPhone / Apple Watch | -| **ntfy** | Simple self-hosted or hosted push | -| **Webhook** | Custom integrations | - -For Apple Watch, install Pushover on your iPhone and enable notification mirroring in the Watch app. +| **ntfy** | Hosted or self-hosted push | +| **Webhook** | Custom integrations and automation | -New to Pushover? See the [Pushover setup guide (简体中文)](./docs/pushover-setup.zh-CN.md) for account registration, app installation, User Key, and Application API Token setup. +New to Pushover? See the [Pushover setup guide (简体中文)](./docs/pushover-setup.zh-CN.md). ## Notification behavior -By default: +Default behavior: | Event | Notify | | --- | --- | @@ -79,11 +58,12 @@ By default: | Input / confirmation required | Yes | | Conversation failed | Yes | | Conversation aborted | No | -| `/knock test` | Immediately | -Completion notifications are sent after Pi reaches `agent_settled`, so retries, queued work, or other automatic continuation do not cause premature “finished” alerts. +Notifications use the project name and, when available, the Pi session name. By default prompt text is **not** included on the lock screen. -A completion notification contains the project name, the original prompt in compact form, and elapsed time. +To change this, run `/knock setup` and choose **Notification preferences**. + +Delivery uses up to three attempts for transient network errors and retryable provider responses such as HTTP 429 / 5xx. Permanent 4xx errors fail immediately. Use `/knock doctor` to inspect the most recent delivery result. ## Configuration @@ -101,49 +81,43 @@ Example: { "projectName": "", "openUrl": "", + "contentMode": "project-only", "notify": { "completed": true, "error": true, "aborted": false, "input": true }, - "ntfy": { - "enabled": false, - "server": "https://ntfy.sh", - "topic": "" - }, "pushover": { "enabled": true - }, - "webhook": { - "enabled": false, - "url": "" } } ``` -See [`pi-knock.example.json`](./pi-knock.example.json) and [`config.schema.json`](./config.schema.json) for the full configuration. +See [`pi-knock.example.json`](./pi-knock.example.json) and [`config.schema.json`](./config.schema.json) for the complete configuration. -Environment variables are also supported, which is useful for Pi-Web, containers, CI, and secret managers. +Environment variables are supported for Pi-Web, containers, CI, and external secret managers. -> Credentials entered through Pi's standard input are visible while typing. Run `/knock setup` only in a private terminal or session. +> Pi's standard input is not masked. Run `/knock setup` only in a private terminal or session when entering credentials. -## Update / remove +## Install from GitHub + +Latest `main`: ```bash -pi update --extensions +pi install git:github.com/Asigers/pi-knock ``` -Remove the npm package: +Update installed extensions: ```bash -pi remove npm:@asigers/pi-knock +pi update --extensions ``` -Remove the Git package: +Remove: ```bash -pi remove git:github.com/Asigers/pi-knock +pi remove npm:@asigers/pi-knock ``` ## Development @@ -151,10 +125,11 @@ pi remove git:github.com/Asigers/pi-knock ```bash npm ci --ignore-scripts npm run check +npm pack --dry-run pi -ne -e ./src/index.ts ``` -Using `-ne` prevents another installed copy of pi-knock from being loaded during local testing. +Using `-ne` prevents another installed copy of pi-knock from loading during local testing. ## More diff --git a/README.zh-CN.md b/README.zh-CN.md index ed3be6e..4964c75 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,72 +2,51 @@ [English](./README.md) | 简体中文 -**Pi 任务完成、失败或需要你操作时,自动给你发通知。** +**离开终端。Pi 需要你的时候会来找你。** -pi-knock 是一个轻量级 Pi 扩展,支持通过 **Pushover**、**ntfy** 或 **Webhook** 推送通知。Pi 在后台运行时,你不需要一直盯着终端等结果。 - -## 功能 - -- ✅ 对话完成时通知 -- ❓ Pi 等待输入或确认时通知 -- ❌ 任务失败时通知 -- ⏹️ 可选:任务中止时通知 -- 📱 支持 Pushover、ntfy、通用 Webhook -- ⌚ 可通过 iPhone 通知镜像推送到 Apple Watch -- 🔗 可附带 Pi-Web 会话地址 -- 🔐 配置和凭据分离存储 - -## 安装 - -推荐使用 npm: +pi-knock 会在 Pi 任务**完成、失败或需要你输入**时发送远程通知。可以通过 Pushover 推送到 iPhone / Apple Watch,通过 ntfy 使用托管或自建推送,也可以通过 Webhook 接入自己的自动化服务。 ```bash pi install npm:@asigers/pi-knock ``` -也可以直接从 GitHub 安装: - -```bash -pi install git:github.com/Asigers/pi-knock -``` - -安装后重启 Pi,或执行: +## 为什么用 pi-knock -```text -/reload -``` - -要求 **Pi 0.87+**。 +- **真正完成后再提醒** — 使用 `agent_settled`,Pi 的自动重试和排队任务都结束后才发送完成通知。 +- **人可以离开电脑** — 通知发到手机或手表,而不是只在当前终端弹一下。 +- **投递更可靠** — 瞬时网络错误会自动重试。 +- **默认保护锁屏隐私** — 默认不把原始 Prompt 放进通知正文,需要时可主动开启。 +- **识别 Session** — 如果给 Pi Session 设置了名称,会显示在通知标题里。 +- **不需要自建推送服务** — pi-knock 直接调用你配置的通知渠道。 ## 快速开始 -执行: +安装后重启 Pi,或执行 `/reload`,然后运行: ```text /knock setup ``` -选择通知渠道并填写配置。保存后,pi-knock 会自动发送一条测试通知。 - -常用命令: +选择通知渠道并填写配置。保存后会立即发送测试通知。 | 命令 | 作用 | | --- | --- | -| `/knock setup` | 配置通知渠道 | +| `/knock setup` | 配置通知渠道和通知偏好 | | `/knock status` | 查看当前配置 | -| `/knock test` | 发送测试通知 | +| `/knock test` | 发送实时测试通知 | +| `/knock doctor` | 查看渠道和最近一次投递诊断 | + +要求 **Pi 0.87+**。 ## 通知渠道 | 渠道 | 适合场景 | | --- | --- | | **Pushover** | iPhone / Apple Watch | -| **ntfy** | 简单的托管或自建推送 | -| **Webhook** | 接入自己的服务或自动化流程 | +| **ntfy** | 托管或自建推送 | +| **Webhook** | 自己的服务和自动化流程 | -如果需要 Apple Watch 通知,在 iPhone 安装 Pushover,并在 Watch App 中开启通知镜像即可。 - -第一次使用 Pushover?查看 **[Pushover 完整配置指南](./docs/pushover-setup.zh-CN.md)**,包含账号注册、App 下载、User Key、Application API Token 和 Apple Watch 配置。 +第一次使用 Pushover?查看 **[Pushover 完整配置指南](./docs/pushover-setup.zh-CN.md)**。 ## 通知规则 @@ -79,15 +58,16 @@ pi install git:github.com/Asigers/pi-knock | 等待输入 / 确认 | 是 | | 对话失败 | 是 | | 对话中止 | 否 | -| `/knock test` | 立即发送 | -完成通知在 Pi 进入 `agent_settled` 后发送,因此重试、排队任务等自动继续执行的情况不会提前触发“任务完成”。 +通知标题会包含项目名;如果当前 Pi Session 设置了名称,也会一起显示。 + +为了避免敏感 Prompt 出现在手机或手表锁屏上,默认使用 **project-only** 模式,不发送原始 Prompt。需要显示 Prompt 时,执行 `/knock setup`,选择 **Notification preferences** 修改。 -完成通知默认包含:**项目名、简化后的原始提示词、执行耗时**。 +对于瞬时网络错误以及 HTTP 429 / 5xx 等可重试错误,pi-knock 最多尝试 3 次;永久性的 4xx 错误不会无意义重试。可以用 `/knock doctor` 查看最近一次投递结果和尝试次数。 ## 配置 -默认配置目录: +默认目录: ```text ~/.pi/agent/pi-knock/ @@ -101,51 +81,43 @@ pi install git:github.com/Asigers/pi-knock { "projectName": "", "openUrl": "", + "contentMode": "project-only", "notify": { "completed": true, "error": true, "aborted": false, "input": true }, - "ntfy": { - "enabled": false, - "server": "https://ntfy.sh", - "topic": "" - }, "pushover": { "enabled": true - }, - "webhook": { - "enabled": false, - "url": "" } } ``` 完整配置见 [`pi-knock.example.json`](./pi-knock.example.json) 和 [`config.schema.json`](./config.schema.json)。 -同时支持环境变量配置,适合 Pi-Web、容器、CI 或外部密钥管理工具。 +同时支持环境变量,适合 Pi-Web、容器、CI 和外部密钥管理工具。 -> Pi 的标准输入框不会隐藏凭据字符。请只在私有终端或私有会话中执行 `/knock setup`。 +> Pi 当前的标准输入框不会隐藏输入内容。填写凭据时,请只在私有终端或私有会话中执行 `/knock setup`。 -## 更新 / 卸载 +## 从 GitHub 安装 -更新: +安装 `main` 最新版本: ```bash -pi update --extensions +pi install git:github.com/Asigers/pi-knock ``` -卸载 npm 版本: +更新: ```bash -pi remove npm:@asigers/pi-knock +pi update --extensions ``` -卸载 Git 版本: +卸载: ```bash -pi remove git:github.com/Asigers/pi-knock +pi remove npm:@asigers/pi-knock ``` ## 开发 @@ -153,6 +125,7 @@ pi remove git:github.com/Asigers/pi-knock ```bash npm ci --ignore-scripts npm run check +npm pack --dry-run pi -ne -e ./src/index.ts ``` diff --git a/config.schema.json b/config.schema.json index 67048a8..8f9d110 100644 --- a/config.schema.json +++ b/config.schema.json @@ -20,36 +20,27 @@ "default": "", "description": "Optional URL opened from providers that support notification links." }, + "contentMode": { + "type": "string", + "enum": ["prompt", "project-only"], + "default": "project-only", + "description": "Whether notifications may include compacted user prompt text." + }, "notify": { "type": "object", "additionalProperties": false, "properties": { - "completed": { - "type": "boolean", - "default": true - }, - "error": { - "type": "boolean", - "default": true - }, - "aborted": { - "type": "boolean", - "default": false - }, - "input": { - "type": "boolean", - "default": true - } + "completed": { "type": "boolean", "default": true }, + "error": { "type": "boolean", "default": true }, + "aborted": { "type": "boolean", "default": false }, + "input": { "type": "boolean", "default": true } } }, "ntfy": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": false - }, + "enabled": { "type": "boolean", "default": false }, "server": { "type": "string", "default": "https://ntfy.sh", @@ -66,20 +57,14 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": false - } + "enabled": { "type": "boolean", "default": false } } }, "webhook": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean", - "default": false - }, + "enabled": { "type": "boolean", "default": false }, "url": { "type": "string", "default": "", diff --git a/package.json b/package.json index 712d17c..25ddf0a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@asigers/pi-knock", "version": "0.2.1", - "description": "Get notified when your Pi agent finishes, fails, or needs your attention.", + "description": "Remote notifications for Pi when a task finishes, fails, or needs your attention.", "type": "module", "license": "MIT", "author": "Asigers", @@ -12,7 +12,9 @@ "notification", "ntfy", "pushover", - "apple-watch" + "apple-watch", + "remote-notification", + "pi-web" ], "repository": { "type": "git", @@ -43,12 +45,14 @@ }, "scripts": { "typecheck": "tsc --noEmit", - "test": "node --test --experimental-strip-types test/*.test.ts", + "test": "node -e \"require('node:fs').rmSync('.test-dist',{recursive:true,force:true})\" && tsc --outDir .test-dist --noEmit false --rewriteRelativeImportExtensions && node --test .test-dist/test/*.test.js", "check": "npm run typecheck && npm test" }, "files": [ "src", + "docs", "README.md", + "README.zh-CN.md", "CHANGELOG.md", "SECURITY.md", "LICENSE", diff --git a/pi-knock.example.json b/pi-knock.example.json index 79a25ac..e4caafa 100644 --- a/pi-knock.example.json +++ b/pi-knock.example.json @@ -2,6 +2,7 @@ "$schema": "./config.schema.json", "projectName": "", "openUrl": "", + "contentMode": "project-only", "notify": { "completed": true, "error": true, diff --git a/src/channels/ntfy.ts b/src/channels/ntfy.ts index 5142b35..6befe98 100644 --- a/src/channels/ntfy.ts +++ b/src/channels/ntfy.ts @@ -1,3 +1,4 @@ +import { httpDeliveryError } from "../errors.js"; import type { KnockEvent, NtfyConfig } from "../types.js"; export async function sendNtfy(config: NtfyConfig, event: KnockEvent): Promise { @@ -7,6 +8,7 @@ export async function sendNtfy(config: NtfyConfig, event: KnockEvent): Promise { @@ -18,5 +19,5 @@ export async function sendPushover(config: PushoverConfig, event: KnockEvent): P body, signal: AbortSignal.timeout(5_000), }); - if (!response.ok) throw new Error("Pushover returned " + response.status); + if (!response.ok) throw httpDeliveryError("Pushover", response.status); } diff --git a/src/channels/webhook.ts b/src/channels/webhook.ts index 4d04442..f9b64ea 100644 --- a/src/channels/webhook.ts +++ b/src/channels/webhook.ts @@ -1,8 +1,12 @@ +import { httpDeliveryError } from "../errors.js"; import type { KnockEvent, WebhookConfig } from "../types.js"; export async function sendWebhook(config: WebhookConfig, event: KnockEvent): Promise { if (!config.enabled || !config.url) return; - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { + "Content-Type": "application/json", + "X-Pi-Knock-Event-Id": event.id, + }; if (config.bearerToken) headers.Authorization = "Bearer " + config.bearerToken; const response = await fetch(config.url, { method: "POST", @@ -10,5 +14,5 @@ export async function sendWebhook(config: WebhookConfig, event: KnockEvent): Pro body: JSON.stringify(event), signal: AbortSignal.timeout(5_000), }); - if (!response.ok) throw new Error("Webhook returned " + response.status); + if (!response.ok) throw httpDeliveryError("Webhook", response.status); } diff --git a/src/commands.ts b/src/commands.ts index f674d27..3ff5426 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -6,8 +6,14 @@ import { saveCredentials, } from "./config.js"; import { projectName } from "./format.js"; -import { notify } from "./notifier.js"; -import type { ChannelName, KnockConfig, KnockCredentials, KnockEvent } from "./types.js"; +import { getLastDeliveryReport, notify } from "./notifier.js"; +import type { + ChannelName, + KnockConfig, + KnockCredentials, + KnockEvent, + NotificationContentMode, +} from "./types.js"; interface CommandOptions { getConfig: () => KnockConfig; @@ -20,6 +26,10 @@ function channelState(enabled: boolean, ready: boolean): string { return ready ? "✓ configured" : "! incomplete"; } +function contentModeLabel(mode: NotificationContentMode): string { + return mode === "prompt" ? "prompt text" : "project only"; +} + function statusText(config: KnockConfig): string { const paths = resolveConfigPaths(); return [ @@ -33,15 +43,49 @@ function statusText(config: KnockConfig): string { "Needs input " + (config.notify.input ? "✓" : "○"), "Errors " + (config.notify.error ? "✓" : "○"), "Aborted " + (config.notify.aborted ? "✓" : "○"), + "Content " + contentModeLabel(config.contentMode), "", "Config " + paths.configFile, "Credentials " + paths.credentialsFile, ].join("\n"); } +function doctorText(config: KnockConfig): string { + const report = getLastDeliveryReport(); + const lines = [ + "pi-knock diagnostics", + "", + "Pushover " + channelState(config.pushover.enabled, Boolean(config.pushover.userKey && config.pushover.appToken)), + "ntfy " + channelState(config.ntfy.enabled, Boolean(config.ntfy.server && config.ntfy.topic)), + "Webhook " + channelState(config.webhook.enabled, Boolean(config.webhook.url)), + "Privacy " + contentModeLabel(config.contentMode), + "", + ]; + + if (!report) { + lines.push("Last delivery none in this Pi session"); + } else if (report.results.length === 0) { + lines.push("Last delivery no configured channel matched"); + } else { + lines.push("Last delivery " + report.timestamp); + for (const result of report.results) { + lines.push( + " " + result.channel + " " + + (result.ok ? "✓" : "✗") + + " (" + result.attempts + (result.attempts === 1 ? " attempt)" : " attempts)") + + (result.error ? " · " + result.error : ""), + ); + } + } + + lines.push("", "Run /knock test to verify live delivery."); + return lines.join("\n"); +} + function testEvent(config: KnockConfig, cwd: string): KnockEvent { const project = projectName(cwd, config.projectName); return { + id: "test-" + Date.now(), type: "completed", title: project + " · pi-knock test", message: "Notifications are working.", @@ -59,7 +103,12 @@ async function sendTest( const results = await notify(config, testEvent(config, cwd), onlyChannels); if (results.length === 0) return "No configured notification channel matched this test."; return results - .map((result) => result.ok ? "✓ " + result.channel : "✗ " + result.channel + ": " + result.error) + .map((result) => { + const attempts = result.attempts > 1 ? " after " + result.attempts + " attempts" : ""; + return result.ok + ? "✓ " + result.channel + attempts + : "✗ " + result.channel + attempts + ": " + result.error; + }) .join("\n"); } @@ -70,9 +119,35 @@ function configWithPushover(config: KnockConfig): KnockConfig { }; } +async function configurePreferences( + config: KnockConfig, + ctx: any, +): Promise { + const privacy = await ctx.ui.select( + "Notification content", + [ + "Project only (recommended for lock screens)", + "Include prompt text", + "Cancel", + ], + ); + if (!privacy || privacy === "Cancel") return config; + + const completed = await ctx.ui.confirm("Completed tasks", "Notify when a conversation finishes?"); + const input = await ctx.ui.confirm("Needs input", "Notify when Pi waits for input or confirmation?"); + const error = await ctx.ui.confirm("Failed tasks", "Notify when a conversation fails?"); + const aborted = await ctx.ui.confirm("Aborted tasks", "Notify when a conversation is aborted?"); + + return { + ...config, + contentMode: privacy.startsWith("Project only") ? "project-only" : "prompt", + notify: { completed, input, error, aborted }, + }; +} + export function registerKnockCommands(pi: ExtensionAPI, options: CommandOptions): void { pi.registerCommand("knock", { - description: "Configure and test pi-knock notifications: /knock setup|status|test", + description: "Configure and test pi-knock notifications: /knock setup|status|test|doctor", handler: async (args, ctx) => { await options.withInternalUi(async () => { const action = args.trim().toLowerCase(); @@ -82,6 +157,11 @@ export function registerKnockCommands(pi: ExtensionAPI, options: CommandOptions) return; } + if (action === "doctor") { + ctx.ui.notify(doctorText(options.getConfig()), "info"); + return; + } + if (action === "test") { const result = await sendTest(options.getConfig(), ctx.cwd); ctx.ui.notify(result, result.includes("✗") || result.startsWith("No ") ? "warning" : "info"); @@ -89,19 +169,34 @@ export function registerKnockCommands(pi: ExtensionAPI, options: CommandOptions) } if (action !== "setup") { - ctx.ui.notify("Usage: /knock setup | /knock status | /knock test", "info"); + ctx.ui.notify("Usage: /knock setup | /knock status | /knock test | /knock doctor", "info"); return; } const provider = await ctx.ui.select( - "Choose a notification provider", - ["Pushover (iPhone / Apple Watch)", "ntfy", "Webhook", "Cancel"], + "pi-knock setup", + [ + "Pushover (iPhone / Apple Watch)", + "ntfy", + "Webhook", + "Notification preferences", + "Cancel", + ], ); if (!provider || provider === "Cancel") return; const current = options.getConfig(); const credentials: KnockCredentials = loadStoredCredentials(); + if (provider === "Notification preferences") { + const updated = await configurePreferences(current, ctx); + if (updated === current) return; + saveConfig(updated); + options.reloadConfig(); + ctx.ui.notify("Notification preferences saved.", "info"); + return; + } + if (provider.startsWith("Pushover")) { ctx.ui.notify( "Pi's standard input is not masked. Enter credentials only in a private terminal. They will be stored in credentials.json with owner-only file permissions where supported.", diff --git a/src/config.ts b/src/config.ts index 3af038d..d67d957 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,13 +1,19 @@ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; -import type { ConfigPaths, KnockConfig, KnockCredentials } from "./types.js"; +import type { + ConfigPaths, + KnockConfig, + KnockCredentials, + NotificationContentMode, +} from "./types.js"; type JsonRecord = Record; const DEFAULT_CONFIG: KnockConfig = { projectName: "", openUrl: "", + contentMode: "project-only", notify: { completed: true, error: true, aborted: false, input: true }, ntfy: { enabled: false, server: "https://ntfy.sh", topic: "", accessToken: "" }, pushover: { enabled: false, userKey: "", appToken: "" }, @@ -45,6 +51,14 @@ function toBoolean(value: string | undefined, fallback: boolean): boolean { return !["0", "false", "no", "off"].includes(value.toLowerCase()); } +function contentModeValue(value: unknown): NotificationContentMode | undefined { + return value === "prompt" || value === "project-only" ? value : undefined; +} + +function envContentMode(value: string | undefined, fallback: NotificationContentMode): NotificationContentMode { + return contentModeValue(value) ?? fallback; +} + function firstDefined(...values: Array): T | undefined { return values.find((value) => value !== undefined); } @@ -129,6 +143,11 @@ function mergeFileSources(legacy: JsonRecord, configFile: JsonRecord, credential stringValue(legacy.openUrl), DEFAULT_CONFIG.openUrl, )!, + contentMode: firstDefined( + contentModeValue(configFile.contentMode), + contentModeValue(legacy.contentMode), + DEFAULT_CONFIG.contentMode, + )!, notify: { completed: firstDefined( booleanValue(fileNotify.completed), @@ -218,6 +237,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): KnockConfig { ...fromFiles, projectName: env.PI_KNOCK_PROJECT ?? fromFiles.projectName, openUrl: env.PI_KNOCK_OPEN_URL ?? fromFiles.openUrl, + contentMode: envContentMode(env.PI_KNOCK_CONTENT_MODE, fromFiles.contentMode), notify: { completed: toBoolean(env.PI_KNOCK_NOTIFY_COMPLETED, fromFiles.notify.completed), error: toBoolean(env.PI_KNOCK_NOTIFY_ERROR, fromFiles.notify.error), @@ -290,6 +310,7 @@ export function saveConfig(config: KnockConfig, env: NodeJS.ProcessEnv = process writeJson(configFile, { projectName: config.projectName, openUrl: config.openUrl, + contentMode: config.contentMode, notify: config.notify, ntfy: { enabled: config.ntfy.enabled, diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..fe6ad58 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,14 @@ +export class DeliveryError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable: boolean) { + super(message); + this.name = "DeliveryError"; + this.retryable = retryable; + } +} + +export function httpDeliveryError(provider: string, status: number): DeliveryError { + const retryable = status === 408 || status === 425 || status === 429 || status >= 500; + return new DeliveryError(provider + " returned " + status, retryable); +} diff --git a/src/format.ts b/src/format.ts index 603d485..b3cbfc6 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,5 +1,6 @@ +import { randomUUID } from "node:crypto"; import { basename } from "node:path"; -import type { KnockEvent, KnockEventType } from "./types.js"; +import type { KnockEvent, KnockEventType, NotificationContentMode } from "./types.js"; export function projectName(cwd: string, configuredName = ""): string { return configuredName.trim() || basename(cwd) || "Pi"; @@ -34,15 +35,23 @@ function label(type: KnockEventType): string { export function makeEvent(input: { type: KnockEventType; project: string; + sessionName?: string; prompt?: string; durationMs?: number; openUrl?: string; inputKind?: string; inputTitle?: string; + contentMode?: NotificationContentMode; }): KnockEvent { - const prompt = input.prompt ? compactText(input.prompt) : undefined; + const contentMode = input.contentMode ?? "prompt"; + const prompt = contentMode === "prompt" && input.prompt ? compactText(input.prompt) : undefined; const duration = input.durationMs === undefined ? "" : ` · ${formatDuration(input.durationMs)}`; - const title = `${input.project} · ${label(input.type)}`; + const sessionName = input.sessionName?.trim() + ? compactText(input.sessionName.trim(), 48) + : undefined; + const context = sessionName ? `${input.project} · ${sessionName}` : input.project; + const title = `${context} · ${label(input.type)}`; + let message: string; if (input.type === "input") { const detail = compactText(input.inputTitle || input.inputKind || "Pi is waiting for input", 100); @@ -50,9 +59,18 @@ export function makeEvent(input: { } else { message = prompt ? `${prompt}${duration}` : `${label(input.type)}${duration}`; } + return { - type: input.type, title, message, project: input.project, prompt, - durationMs: input.durationMs, openUrl: input.openUrl || undefined, - timestamp: new Date().toISOString(), inputKind: input.inputKind, + id: randomUUID(), + type: input.type, + title, + message, + project: input.project, + sessionName, + prompt, + durationMs: input.durationMs, + openUrl: input.openUrl || undefined, + timestamp: new Date().toISOString(), + inputKind: input.inputKind, }; } diff --git a/src/index.ts b/src/index.ts index 517cf0f..f38c183 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { notify } from "./notifier.js"; import type { KnockEventType } from "./types.js"; interface RunState { + activeRun: boolean; startedAt: number | null; prompt: string; outcome: "completed" | "aborted" | "error"; @@ -16,6 +17,7 @@ interface RunState { export default function piKnock(pi: ExtensionAPI): void { let config = loadConfig(); const state: RunState = { + activeRun: false, startedAt: null, prompt: "", outcome: "completed", @@ -48,11 +50,13 @@ export default function piKnock(pi: ExtensionAPI): void { const event = makeEvent({ type, project: projectName(ctx.cwd, config.projectName), + sessionName: pi.getSessionName() || undefined, prompt: state.prompt, durationMs: extra.durationMs, openUrl: config.openUrl, inputKind: extra.inputKind, inputTitle: extra.inputTitle, + contentMode: config.contentMode, }); const results = await notify(config, event); const failed = results.filter((result) => !result.ok); @@ -82,6 +86,7 @@ export default function piKnock(pi: ExtensionAPI): void { pi.on("before_agent_start", async (event) => { config = loadConfig(); + state.activeRun = true; state.startedAt = Date.now(); state.prompt = event.prompt; state.outcome = "completed"; @@ -93,7 +98,7 @@ export default function piKnock(pi: ExtensionAPI): void { }); pi.on("ui_prompt_start", async (event, ctx) => { - if (state.internalUiDepth > 0) return; + if (!state.activeRun || state.internalUiDepth > 0) return; const key = event.kind + ":" + (event.title ?? ""); if (state.lastPromptNotificationKey === key) return; @@ -107,9 +112,12 @@ export default function piKnock(pi: ExtensionAPI): void { }); pi.on("agent_settled", async (_event, ctx) => { + if (!state.activeRun) return; + const durationMs = state.startedAt === null ? 0 : Date.now() - state.startedAt; const type: KnockEventType = state.outcome; + state.activeRun = false; await deliver(type, ctx, { durationMs }); state.startedAt = null; diff --git a/src/notifier.ts b/src/notifier.ts index ad0d785..fe62dbc 100644 --- a/src/notifier.ts +++ b/src/notifier.ts @@ -1,14 +1,72 @@ +import { DeliveryError } from "./errors.js"; import { sendNtfy } from "./channels/ntfy.js"; import { sendPushover } from "./channels/pushover.js"; import { sendWebhook } from "./channels/webhook.js"; -import type { ChannelName, KnockConfig, KnockEvent } from "./types.js"; +import type { ChannelName, KnockConfig, KnockEvent, KnockEventType } from "./types.js"; export interface DeliveryResult { channel: ChannelName; ok: boolean; + attempts: number; error?: string; } +export interface DeliveryReport { + timestamp: string; + eventType: KnockEventType; + results: DeliveryResult[]; +} + +interface RetryOptions { + retryDelaysMs?: number[]; + sleep?: (ms: number) => Promise; +} + +const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000]; +let lastDeliveryReport: DeliveryReport | undefined; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryable(error: unknown): boolean { + return error instanceof DeliveryError ? error.retryable : true; +} + +export async function runWithRetry( + channel: ChannelName, + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; + const wait = options.sleep ?? sleep; + let attempts = 0; + let lastError: unknown; + + for (let index = 0; index <= retryDelaysMs.length; index += 1) { + attempts += 1; + try { + await fn(); + return { channel, ok: true, attempts }; + } catch (error) { + lastError = error; + if (!isRetryable(error) || index === retryDelaysMs.length) break; + await wait(retryDelaysMs[index]); + } + } + + return { + channel, + ok: false, + attempts, + error: lastError instanceof Error ? lastError.message : String(lastError), + }; +} + +export function getLastDeliveryReport(): DeliveryReport | undefined { + return lastDeliveryReport; +} + export async function notify( config: KnockConfig, event: KnockEvent, @@ -23,7 +81,7 @@ export async function notify( config.ntfy.topic && config.ntfy.server ) { - jobs.push(run("ntfy", () => sendNtfy(config.ntfy, event))); + jobs.push(runWithRetry("ntfy", () => sendNtfy(config.ntfy, event))); } if ( @@ -32,7 +90,7 @@ export async function notify( config.pushover.userKey && config.pushover.appToken ) { - jobs.push(run("pushover", () => sendPushover(config.pushover, event))); + jobs.push(runWithRetry("pushover", () => sendPushover(config.pushover, event))); } if ( @@ -40,21 +98,14 @@ export async function notify( config.webhook.enabled && config.webhook.url ) { - jobs.push(run("webhook", () => sendWebhook(config.webhook, event))); + jobs.push(runWithRetry("webhook", () => sendWebhook(config.webhook, event))); } - return Promise.all(jobs); -} - -async function run(channel: DeliveryResult["channel"], fn: () => Promise): Promise { - try { - await fn(); - return { channel, ok: true }; - } catch (error) { - return { - channel, - ok: false, - error: error instanceof Error ? error.message : String(error), - }; - } + const results = await Promise.all(jobs); + lastDeliveryReport = { + timestamp: new Date().toISOString(), + eventType: event.type, + results, + }; + return results; } diff --git a/src/types.ts b/src/types.ts index 44f7451..994077a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,14 @@ export type KnockEventType = "completed" | "error" | "aborted" | "input"; export type ChannelName = "ntfy" | "pushover" | "webhook"; +export type NotificationContentMode = "prompt" | "project-only"; export interface KnockEvent { + id: string; type: KnockEventType; title: string; message: string; project: string; + sessionName?: string; prompt?: string; durationMs?: number; openUrl?: string; @@ -42,6 +45,7 @@ export interface WebhookConfig { export interface KnockConfig { projectName: string; openUrl: string; + contentMode: NotificationContentMode; notify: NotifyConfig; ntfy: NtfyConfig; pushover: PushoverConfig; diff --git a/test/channels.test.ts b/test/channels.test.ts new file mode 100644 index 0000000..2afc3e7 --- /dev/null +++ b/test/channels.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { sendNtfy } from "../src/channels/ntfy.ts"; +import { sendPushover } from "../src/channels/pushover.ts"; +import { sendWebhook } from "../src/channels/webhook.ts"; +import type { KnockEvent } from "../src/types.ts"; + +const event: KnockEvent = { + id: "event-123", + type: "completed", + title: "demo · Task finished", + message: "Finished in 1m", + project: "demo", + timestamp: "2026-10-04T00:00:00.000Z", + openUrl: "https://example.com/session", +}; + +test("Pushover sends the expected form payload", async () => { + const originalFetch = globalThis.fetch; + let request: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + request = { url: String(url), init }; + return new Response("{}", { status: 200 }); + }) as typeof fetch; + + try { + await sendPushover( + { enabled: true, userKey: "user-key", appToken: "app-token" }, + event, + ); + assert.equal(request?.url, "https://api.pushover.net/1/messages.json"); + const body = request?.init?.body as URLSearchParams; + assert.equal(body.get("user"), "user-key"); + assert.equal(body.get("token"), "app-token"); + assert.equal(body.get("url"), event.openUrl); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("ntfy encodes topics and includes the event id", async () => { + const originalFetch = globalThis.fetch; + let request: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + request = { url: String(url), init }; + return new Response("", { status: 200 }); + }) as typeof fetch; + + try { + await sendNtfy( + { + enabled: true, + server: "https://ntfy.example/", + topic: "pi alerts", + accessToken: "secret", + }, + event, + ); + assert.equal(request?.url, "https://ntfy.example/pi%20alerts"); + const headers = request?.init?.headers as Record; + assert.equal(headers.Authorization, "Bearer secret"); + assert.equal(headers["X-Pi-Knock-Event-Id"], event.id); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("webhook sends a JSON event with a stable event id header", async () => { + const originalFetch = globalThis.fetch; + let request: { url: string; init?: RequestInit } | undefined; + globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => { + request = { url: String(url), init }; + return new Response(null, { status: 204 }); + }) as typeof fetch; + + try { + await sendWebhook( + { enabled: true, url: "https://example.com/hook", bearerToken: "token" }, + event, + ); + assert.equal(request?.url, "https://example.com/hook"); + const headers = request?.init?.headers as Record; + assert.equal(headers.Authorization, "Bearer token"); + assert.equal(headers["X-Pi-Knock-Event-Id"], event.id); + assert.deepEqual(JSON.parse(String(request?.init?.body)), event); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/test/config.test.ts b/test/config.test.ts index 3253d44..9da2498 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -24,6 +24,7 @@ test("new environment variable names configure channels", () => { PI_KNOCK_PUSHOVER_USER_KEY: "user-key", PI_KNOCK_PUSHOVER_APP_TOKEN: "app-token", PI_KNOCK_NOTIFY_ABORTED: "true", + PI_KNOCK_CONTENT_MODE: "prompt", }; const config = loadConfig(env); assert.equal(config.ntfy.topic, "agent-events"); @@ -33,6 +34,7 @@ test("new environment variable names configure channels", () => { assert.equal(config.pushover.appToken, "app-token"); assert.equal(config.pushover.enabled, true); assert.equal(config.notify.aborted, true); + assert.equal(config.contentMode, "prompt"); }); test("legacy environment variable names remain supported", () => { @@ -120,3 +122,17 @@ test("loadStoredCredentials never reads environment secrets", () => { const stored = loadStoredCredentials(env); assert.equal(stored.pushover, undefined); }); + + +test("project-only is the safe default and is persisted", () => { + const env = tempEnv(); + const config = loadConfig(env); + assert.equal(config.contentMode, "project-only"); + + config.contentMode = "prompt"; + saveConfig(config, env); + + const saved = JSON.parse(readFileSync(resolveConfigPaths(env).configFile, "utf8")); + assert.equal(saved.contentMode, "prompt"); + assert.equal(loadConfig(env).contentMode, "prompt"); +}); diff --git a/test/format.test.ts b/test/format.test.ts index eae77bb..026a2c9 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -21,3 +21,20 @@ test("makeEvent includes project, prompt and duration", () => { assert.match(event.message, /Fix the test suite/); assert.match(event.message, /1m 5s/); }); + + +test("makeEvent can hide prompt text and include a session name", () => { + const event = makeEvent({ + type: "completed", + project: "demo", + sessionName: "Auth refactor", + prompt: "sensitive production prompt", + durationMs: 65_000, + contentMode: "project-only", + }); + + assert.match(event.title, /demo · Auth refactor · Task finished/); + assert.equal(event.prompt, undefined); + assert.doesNotMatch(event.message, /sensitive production prompt/); + assert.ok(event.id); +}); diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts new file mode 100644 index 0000000..5fc1802 --- /dev/null +++ b/test/lifecycle.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import piKnock from "../src/index.ts"; + +type Handler = (...args: any[]) => Promise | void; + +test("lifecycle sends one settled notification, includes session name, and suppresses duplicates", async () => { + const oldHome = process.env.PI_KNOCK_HOME; + const oldWebhook = process.env.PI_KNOCK_WEBHOOK_URL; + const oldMode = process.env.PI_KNOCK_CONTENT_MODE; + const oldFetch = globalThis.fetch; + + process.env.PI_KNOCK_HOME = mkdtempSync(join(tmpdir(), "pi-knock-lifecycle-")); + process.env.PI_KNOCK_WEBHOOK_URL = "https://example.com/hook"; + process.env.PI_KNOCK_CONTENT_MODE = "project-only"; + + const requests: Array<{ body: any; headers: Record }> = []; + globalThis.fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requests.push({ + body: JSON.parse(String(init?.body)), + headers: init?.headers as Record, + }); + return new Response(null, { status: 204 }); + }) as typeof fetch; + + const handlers = new Map(); + const commands = new Map(); + const pi = { + on(name: string, handler: Handler) { + handlers.set(name, handler); + }, + registerCommand(name: string, command: any) { + commands.set(name, command); + }, + getSessionName() { + return "Auth refactor"; + }, + } as any; + + const ctx = { + cwd: "/tmp/demo-project", + hasUI: true, + ui: { + notify() {}, + select: async () => "Cancel", + confirm: async () => false, + input: async () => undefined, + }, + }; + + try { + piKnock(pi); + + await handlers.get("before_agent_start")?.({ prompt: "secret prompt text" }); + await handlers.get("ui_prompt_start")?.({ kind: "confirm", title: "Continue?" }, ctx); + await handlers.get("ui_prompt_start")?.({ kind: "confirm", title: "Continue?" }, ctx); + assert.equal(requests.length, 1, "duplicate blocking prompts should notify once"); + + await handlers.get("agent_before_settle")?.({ outcome: "completed" }); + await handlers.get("agent_settled")?.({}, ctx); + await handlers.get("agent_settled")?.({}, ctx); + + assert.equal(requests.length, 2, "a settled run should produce one completion notification"); + assert.match(requests[1].body.title, /demo-project · Auth refactor · Task finished/); + assert.equal(requests[1].body.prompt, undefined); + assert.doesNotMatch(requests[1].body.message, /secret prompt text/); + assert.equal(requests[1].headers["X-Pi-Knock-Event-Id"], requests[1].body.id); + + await handlers.get("before_agent_start")?.({ prompt: "next task" }); + await handlers.get("agent_before_settle")?.({ outcome: "aborted" }); + await handlers.get("agent_settled")?.({}, ctx); + assert.equal(requests.length, 2, "aborted runs are silent by default"); + + await handlers.get("before_agent_start")?.({ prompt: "broken task" }); + await handlers.get("agent_before_settle")?.({ outcome: "error" }); + await handlers.get("agent_settled")?.({}, ctx); + assert.equal(requests.length, 3); + assert.match(requests[2].body.title, /Task failed/); + + assert.ok(commands.has("knock")); + } finally { + globalThis.fetch = oldFetch; + if (oldHome === undefined) delete process.env.PI_KNOCK_HOME; + else process.env.PI_KNOCK_HOME = oldHome; + if (oldWebhook === undefined) delete process.env.PI_KNOCK_WEBHOOK_URL; + else process.env.PI_KNOCK_WEBHOOK_URL = oldWebhook; + if (oldMode === undefined) delete process.env.PI_KNOCK_CONTENT_MODE; + else process.env.PI_KNOCK_CONTENT_MODE = oldMode; + } +}); diff --git a/test/notifier.test.ts b/test/notifier.test.ts new file mode 100644 index 0000000..ced39ab --- /dev/null +++ b/test/notifier.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DeliveryError } from "../src/errors.ts"; +import { runWithRetry } from "../src/notifier.ts"; + +test("runWithRetry succeeds after transient failures", async () => { + let calls = 0; + const result = await runWithRetry( + "webhook", + async () => { + calls += 1; + if (calls < 3) throw new Error("temporary network failure"); + }, + { retryDelaysMs: [1, 1], sleep: async () => {} }, + ); + + assert.equal(result.ok, true); + assert.equal(result.attempts, 3); + assert.equal(calls, 3); +}); + +test("runWithRetry does not retry permanent delivery errors", async () => { + let calls = 0; + const result = await runWithRetry( + "pushover", + async () => { + calls += 1; + throw new DeliveryError("Pushover returned 400", false); + }, + { retryDelaysMs: [1, 1], sleep: async () => {} }, + ); + + assert.equal(result.ok, false); + assert.equal(result.attempts, 1); + assert.equal(calls, 1); +});