From a50cf5e12681c9a11d8a9293bba628b5755be735 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:49:26 +0800 Subject: [PATCH 01/32] feat: harden pi-knock for product use --- src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) 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; From 74ae8a41c383f8d02a9fca2626c1296dee5d150e Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:49:28 +0800 Subject: [PATCH 02/32] feat: harden pi-knock for product use --- src/config.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 3af038d..468ec18 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: "prompt", 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, From b4e12aea372baed47dd57805d8ef234a07c41f8c Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:49:30 +0800 Subject: [PATCH 03/32] feat: harden pi-knock for product use --- src/format.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) 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, }; } From a3956f85c3359d626ae216ba5c08ab984e74335d Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:49:33 +0800 Subject: [PATCH 04/32] feat: harden pi-knock for product use --- src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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; From cb7f4af2078976dd0617844d5908cae9de8a6b6f Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:50:53 +0800 Subject: [PATCH 05/32] feat: add retry-aware delivery errors --- src/errors.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/errors.ts diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..831932d --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,11 @@ +export class DeliveryError extends Error { + constructor(message: string, public readonly retryable: boolean) { + super(message); + this.name = "DeliveryError"; + } +} + +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); +} From 792f98226fccb6209cdb9772860459636a4849a1 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:50:55 +0800 Subject: [PATCH 06/32] feat: retry transient notification failures --- src/notifier.ts | 87 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 18 deletions(-) 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; } From ae130627a7f03dedfae239faf74c2f166e402cd8 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:50:58 +0800 Subject: [PATCH 07/32] feat: add diagnostics and notification preferences --- src/commands.ts | 109 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 102 insertions(+), 7 deletions(-) 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.", From dcc5fde329c603804cdccd804e49fc2a923866b6 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:14 +0800 Subject: [PATCH 08/32] feat: classify provider delivery errors --- src/channels/pushover.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/channels/pushover.ts b/src/channels/pushover.ts index 744abf2..5a042af 100644 --- a/src/channels/pushover.ts +++ b/src/channels/pushover.ts @@ -1,3 +1,4 @@ +import { httpDeliveryError } from "../errors.js"; import type { KnockEvent, PushoverConfig } from "../types.js"; export async function sendPushover(config: PushoverConfig, 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); } From d64ef574051d8295dcb2717da5e8704b20cd711a Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:16 +0800 Subject: [PATCH 09/32] feat: classify provider delivery errors --- src/channels/ntfy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 Date: Sun, 4 Oct 2026 14:51:18 +0800 Subject: [PATCH 10/32] feat: classify provider delivery errors --- src/channels/webhook.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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); } From fb32cecbfecf2cf6afd5b4a44db3f7c3cacb9ff1 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:37 +0800 Subject: [PATCH 11/32] feat: add notification privacy setting --- config.schema.json | 41 +++++++++++++---------------------------- 1 file changed, 13 insertions(+), 28 deletions(-) diff --git a/config.schema.json b/config.schema.json index 67048a8..a885cce 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": "prompt", + "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": "", From 003c6c4450f9002fedff2cd8c7142136ab9153f4 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:40 +0800 Subject: [PATCH 12/32] feat: document notification privacy setting --- pi-knock.example.json | 1 + 1 file changed, 1 insertion(+) 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, From 96608f523ff5c9a916856aa0342e799afb59cf02 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:51:42 +0800 Subject: [PATCH 13/32] build: include docs in npm package --- package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 712d17c..c1912e5 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", @@ -48,7 +50,9 @@ }, "files": [ "src", + "docs", "README.md", + "README.zh-CN.md", "CHANGELOG.md", "SECURITY.md", "LICENSE", From 4c797297baf865597ad4662048d68a5ab663160b Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:00 +0800 Subject: [PATCH 14/32] ci: verify npm package contents --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) 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 From 86e46c35cd1d4ab3b0c5acb2500d2b190ff94388 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:03 +0800 Subject: [PATCH 15/32] ci: verify package before publishing --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) 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" From 1a3734cd6eda9f109335cb63ef3040a388e54351 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:05 +0800 Subject: [PATCH 16/32] docs: record product hardening changes --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e83ad2..0d87034 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. +- 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 From b222140c0684415d5cfed990cdd4e4b3df5fefb5 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:07 +0800 Subject: [PATCH 17/32] docs: document lifecycle and package checks --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) 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. From ed3bd36621d37dd058465a5f3277df338a289412 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:37 +0800 Subject: [PATCH 18/32] test: cover notification retries --- test/notifier.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/notifier.test.ts 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); +}); From 8e58455f00ca2c12d210a813a6cbd4b88d65ba82 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:40 +0800 Subject: [PATCH 19/32] test: cover notification providers --- test/channels.test.ts | 89 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 test/channels.test.ts diff --git a/test/channels.test.ts b/test/channels.test.ts new file mode 100644 index 0000000..0064c9a --- /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("", { 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; + } +}); From e7e3f1c95d92642a218954a99179901a1b8b6531 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:52:42 +0800 Subject: [PATCH 20/32] test: cover Pi lifecycle behavior --- test/lifecycle.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/lifecycle.test.ts diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts new file mode 100644 index 0000000..5998968 --- /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("", { 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; + } +}); From 46d2dabff32877eec96cd04dcbf2a635bbeb1138 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:15 +0800 Subject: [PATCH 21/32] feat: default to lock-screen-safe notification content --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 468ec18..d67d957 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,7 +13,7 @@ type JsonRecord = Record; const DEFAULT_CONFIG: KnockConfig = { projectName: "", openUrl: "", - contentMode: "prompt", + 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: "" }, From 8835751c2d9db59b31a9761d54cd989b63e40f27 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:18 +0800 Subject: [PATCH 22/32] docs: make project-only the safe default --- config.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.schema.json b/config.schema.json index a885cce..8f9d110 100644 --- a/config.schema.json +++ b/config.schema.json @@ -23,7 +23,7 @@ "contentMode": { "type": "string", "enum": ["prompt", "project-only"], - "default": "prompt", + "default": "project-only", "description": "Whether notifications may include compacted user prompt text." }, "notify": { From 78cfce968dc0b1a4233eb4a8ec1d33d73f8fcc62 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:20 +0800 Subject: [PATCH 23/32] docs: note privacy default --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d87034..1adbf2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and - 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. +- 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. From bc729e2d9e9051ea1aaf5d81e8581d8299acd7a6 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:32 +0800 Subject: [PATCH 24/32] test: cover privacy configuration --- test/config.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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"); +}); From d0c1f477b4a9f1e00c3ffdf71babd639c2f3c21f Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:53:35 +0800 Subject: [PATCH 25/32] test: cover private session notifications --- test/format.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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); +}); From 94827f6c9270f07c66a4359877f6928ea90b3aac Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:54:24 +0800 Subject: [PATCH 26/32] docs: sharpen product positioning --- README.md | 101 ++++++++++++++++++++---------------------------------- 1 file changed, 38 insertions(+), 63 deletions(-) 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 From a9b91a2c74bb35201e4d860d72a9ece8b541db72 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:54:26 +0800 Subject: [PATCH 27/32] docs: sharpen Chinese product positioning --- README.zh-CN.md | 97 ++++++++++++++++++------------------------------- 1 file changed, 35 insertions(+), 62 deletions(-) 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 ``` From 512bd8c86eca0aec0eb5a1822dcee6221897394b Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:56:12 +0800 Subject: [PATCH 28/32] test: compile TypeScript before running tests --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c1912e5..25ddf0a 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "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": [ From 2d05c4ae5c3d73796f1ea56c1f00077b05afdf46 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:56:14 +0800 Subject: [PATCH 29/32] fix: avoid strip-only TypeScript syntax --- src/errors.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 831932d..fe6ad58 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,7 +1,10 @@ export class DeliveryError extends Error { - constructor(message: string, public readonly retryable: boolean) { + readonly retryable: boolean; + + constructor(message: string, retryable: boolean) { super(message); this.name = "DeliveryError"; + this.retryable = retryable; } } From 51cf2ac92d6d9d1630205726f42cf1d6cb560155 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:56:16 +0800 Subject: [PATCH 30/32] build: ignore compiled test output --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 38227ab1511339b4bb6cada495e6eef571fb2bbf Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:57:10 +0800 Subject: [PATCH 31/32] test: use valid 204 response mocks --- test/channels.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/channels.test.ts b/test/channels.test.ts index 0064c9a..2afc3e7 100644 --- a/test/channels.test.ts +++ b/test/channels.test.ts @@ -70,7 +70,7 @@ test("webhook sends a JSON event with a stable event id header", async () => { 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: 204 }); + return new Response(null, { status: 204 }); }) as typeof fetch; try { From c2df611e2eda7522ac20c4476f4e6f41960bddd3 Mon Sep 17 00:00:00 2001 From: lylezhang <121010864+Asigers@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:57:12 +0800 Subject: [PATCH 32/32] test: use valid 204 response mocks --- test/lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/lifecycle.test.ts b/test/lifecycle.test.ts index 5998968..5fc1802 100644 --- a/test/lifecycle.test.ts +++ b/test/lifecycle.test.ts @@ -23,7 +23,7 @@ test("lifecycle sends one settled notification, includes session name, and suppr body: JSON.parse(String(init?.body)), headers: init?.headers as Record, }); - return new Response("", { status: 204 }); + return new Response(null, { status: 204 }); }) as typeof fetch; const handlers = new Map();