feat: notify on focused agent completions with sequence deduplication

This commit is contained in:
dmahacker 2026-10-15 12:43:48 +08:00
parent 8945030287
commit 3bd6d2dc8e
4 changed files with 200 additions and 144 deletions

View File

@ -2,7 +2,9 @@
> Stop babysitting your AI agents. Let them text you when they need help or finish the job.
A lightweight, zero-dependency Herdr v1 plugin that sends structured push notifications to your [ntfy](https://ntfy.sh) topic on terminal agent status changes (`done` & `blocked`).
A zero-dependency Herdr plugin that sends structured push notifications to your [ntfy](https://ntfy.sh) topic when agents finish or need action, **even while you are viewing their tab**. Requires Herdr 0.9.5+ and Node.js 22+.
Fork of [zom-2018/herdr-ntfy-notify](https://github.com/zom-2018/herdr-ntfy-notify). The original plugin ID is retained to reuse existing local configuration.
---
@ -18,19 +20,28 @@ A lightweight, zero-dependency Herdr v1 plugin that sends structured push notifi
## ⚙️ Quick Start
### 1. Link the Plugin
Tell your local Herdr daemon about this plugin:
### 1. Clone over public HTTPS and link
Herdr 0.9.5's `plugin install` only accepts GitHub repositories. For this repository, use Git and the local plugin link command:
```bash
herdr plugin link /opt/herdr-ntfy-notify
git clone https://git.qhack.cn/dma/herdr-ntfy-notify.git ~/herdr-ntfy-notify
herdr plugin link ~/herdr-ntfy-notify --enabled
```
If the upstream plugin is already installed, linking this fork replaces its registration under the same ID. Existing configuration stays in place, and only one version is registered. Do not uninstall with config/state purging.
### 2. Configure
Copy the configuration template and edit it:
Existing installations keep their `.env` in the plugin config directory. For a new installation only:
```bash
CONFIG_DIR="$(herdr plugin config-dir local.agent-ntfy-notify)"
cp /opt/herdr-ntfy-notify/.env.example "$CONFIG_DIR/.env"
CONFIG_DIR="$(herdr plugin config-dir zom-2018.herdr-ntfy-notify)"
cp ~/herdr-ntfy-notify/.env.example "$CONFIG_DIR/.env"
```
Edit that local file; never commit your ntfy topic credentials.
Configure your `.env` variables:
```dotenv
HERDR_NTFY_EXTERNAL_URL=http://localhost:10081
@ -42,18 +53,36 @@ HERDR_NTFY_ENABLED=1
### 3. Test Drive
Send a mock notification to verify the piping:
```bash
herdr plugin action invoke test --plugin local.agent-ntfy-notify
herdr plugin action invoke test --plugin zom-2018.herdr-ntfy-notify
```
---
## 🔬 Deterministic Verification
## Completion detection and limits
Herdr reports an unseen completion as `done`, but a viewed completion as `idle`. The plugin queries `herdr agent get <pane>` and uses `completion_seq`, which is independent of focus. Ordinary idle panes without a completion sequence do not generate an alert. Blocked alerts use `state_change_seq`.
Atomic receipt files in `HERDR_PLUGIN_STATE_DIR/notification-receipts` deduplicate concurrent hooks, metadata updates and read acknowledgements. Receipt keys include the server socket incarnation, terminal, status and sequence. Failed sends release their receipt so a later event may retry. No daemon restart or change to Herdr's built-in toast settings is required.
This is best-effort notification delivery, not a durable queue: a pane that closes or starts another turn before the hook queries it may no longer expose its completion. A killed hook can leave a receipt without delivery; a network failure after ntfy accepted a request can still produce duplicates. Receipts are small empty files retained for deduplication; remove old receipts only when their completions can no longer be replayed. Linux is tested; macOS/Windows are not validated for this fork.
To update a linked installation:
Run the built-in test suite to see how different statuses and agent formats render:
```bash
cd /opt/herdr-ntfy-notify && node test_cases.js
git -C ~/herdr-ntfy-notify pull --ff-only
herdr plugin link ~/herdr-ntfy-notify --enabled
```
## Verification
Tests use an isolated temporary directory, a fake Herdr CLI and a loopback HTTP server. They never publish to your configured ntfy topic:
```bash
node --test test_cases.js
```
Coverage includes foreground/background completion, normal idle, read acknowledgements, concurrent hooks, independent terminals/servers, blocked alerts, disabled mode, malformed events, API errors and delivery retry.
## 📄 License
MIT License. See [LICENSE](LICENSE) for details.

View File

@ -1,8 +1,8 @@
id = "zom-2018.herdr-ntfy-notify"
name = "Herdr ntfy Notification Plugin"
version = "0.1.0"
min_herdr_version = "0.7.0"
description = "Send ntfy pushes when Herdr agents finish or need action."
version = "0.2.0"
min_herdr_version = "0.9.5"
description = "Send ntfy pushes when agents finish or need action, even in the active tab."
platforms = ["linux", "macos", "windows"]
[[actions]]

View File

@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import { createHash } from 'node:crypto';
// Helper to parse .env file
function loadEnv(filePath) {
@ -64,7 +65,7 @@ async function resolveNtfyUrl() {
// Publish using curl command (fast and handles local tailnet SSL gracefully if any)
function publishWithCurl(url, headers, body) {
try {
const args = ['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST'];
const args = ['curl', '-s', '--max-time', '10', '-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST'];
for (const [key, value] of Object.entries(headers)) {
args.push('-H', `${key}: ${value}`);
}
@ -73,7 +74,7 @@ function publishWithCurl(url, headers, body) {
// Escape args safely for exec
const cmd = args.map(arg => `'${arg.replace(/'/g, "'\\''")}'`).join(' ');
const stdout = execSync(cmd, { stdio: 'pipe' }).toString().trim();
const stdout = execSync(cmd, { stdio: 'pipe', timeout: 12000 }).toString().trim();
const statusCode = parseInt(stdout, 10);
return statusCode >= 200 && statusCode < 300;
} catch (err) {
@ -172,17 +173,32 @@ async function run() {
}
}
const status = (
event?.data?.agent_status ||
context?.focused_pane_status ||
context?.agent_status ||
context?.status ||
''
).toLowerCase();
if (!['pane_agent_status_changed', 'pane.agent_status_changed'].includes(event?.event)) return;
const eventStatus = event?.data?.agent_status;
const target = event?.data?.pane_id;
if (!target || !['idle', 'done', 'blocked'].includes(eventStatus)) return;
if (status !== 'done' && status !== 'blocked') {
let live;
// shortcut: hooks read current state, use a durable event queue if every transient turn must be delivered.
try {
const response = JSON.parse(execFileSync(
process.env.HERDR_BIN_PATH || 'herdr', ['agent', 'get', target],
{ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'] },
));
if (response.error || !response.result?.agent) throw new Error('Agent not found');
live = response.result.agent;
} catch (err) {
console.error('Herdr agent lookup error:', err.message);
return;
}
const completed = ['idle', 'done'].includes(live.agent_status);
const status = completed ? 'done' : live.agent_status;
if (status !== 'done' && status !== 'blocked') return;
if ((eventStatus === 'blocked') !== (status === 'blocked')) return;
const sequence = completed ? live.completion_seq : live.state_change_seq;
// completion_seq distinguishes a real foreground completion from an ordinary idle pane.
if (!Number.isSafeInteger(sequence) || sequence <= 0 || !live.terminal_id) return;
event.data = { ...event.data, ...live };
const agent = event?.data?.display_agent || event?.data?.agent || context?.focused_pane_agent || context?.agent || 'Agent';
const workspace = event?.data?.workspace || context?.focused_pane_workspace || context?.workspace || '';
@ -233,7 +249,31 @@ async function run() {
tags: getTags(status, agent, body),
};
await sendNotification(notification);
const socket = process.env.HERDR_SOCKET_PATH;
const stateDir = process.env.HERDR_PLUGIN_STATE_DIR;
if (!socket || !stateDir) throw new Error('Herdr plugin socket/state directory is missing');
const socketStat = fs.statSync(socket);
const key = createHash('sha256').update(JSON.stringify([
socket, socketStat.ino, socketStat.birthtimeMs, socketStat.ctimeMs,
live.terminal_id, status, sequence,
])).digest('hex');
const receipts = path.join(stateDir, 'notification-receipts');
// shortcut: retain small receipts, add safe retention if their count becomes significant.
fs.mkdirSync(receipts, { recursive: true });
const receipt = path.join(receipts, key);
try {
// Exclusive creation also deduplicates concurrent event-hook processes.
fs.writeFileSync(receipt, '', { flag: 'wx', mode: 0o600 });
} catch (err) {
if (err.code === 'EEXIST') return;
throw err;
}
try {
await sendNotification(notification);
} catch (err) {
fs.unlinkSync(receipt);
throw err;
}
}
async function sendNotification({ title, body, priority, tags }) {
@ -262,19 +302,10 @@ async function sendNotification({ title, body, priority, tags }) {
}
// 2. Fallback to native Node fetch
try {
const res = await fetch(url, {
method: 'POST',
headers,
body,
});
if (!res.ok) {
const responseText = await res.text();
console.error(`ntfy publish failed (fetch): ${res.status} ${responseText}`);
}
} catch (err) {
console.error("ntfy publish failed with network error:", err.message);
}
const res = await fetch(url, {
method: 'POST', headers, body, signal: AbortSignal.timeout(10000),
});
if (!res.ok) throw new Error(`ntfy publish failed: HTTP ${res.status}`);
}
run().catch(err => {

View File

@ -1,109 +1,105 @@
import { execSync } from 'child_process';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const testCases = [
{
name: "Case 1: Agent Done (Standard)",
env: {
HERDR_SESSION: "dev-session",
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
event: "pane.agent_status_changed",
data: {
type: "pane_agent_status_changed",
pane_id: "p1",
workspace_id: "my-project",
agent_status: "done",
agent: "Codex",
title: "Refactor login controller"
}
}),
HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({
focused_pane_workspace: "my-project",
focused_pane_tab: "editor",
focused_pane_id: "p1"
})
}
},
{
name: "Case 2: Agent Blocked (Question / Prompt)",
env: {
HERDR_SESSION: "infra-deploy",
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
event: "pane.agent_status_changed",
data: {
type: "pane_agent_status_changed",
pane_id: "p2",
workspace_id: "deployment-infra",
agent_status: "blocked",
agent: "Hermes",
custom_status: "Invalid credentials for AWS. Please provide valid AWS_ACCESS_KEY_ID."
}
}),
HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({
focused_pane_workspace: "deployment-infra",
focused_pane_tab: "TUI",
focused_pane_id: "p2"
})
}
},
{
name: "Case 3: Agent Blocked (Error match)",
env: {
HERDR_SESSION: "web-testing",
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
event: "pane.agent_status_changed",
data: {
type: "pane_agent_status_changed",
pane_id: "p3",
workspace_id: "web-app",
agent_status: "blocked",
agent: "OMP",
state_labels: {
error: "ReferenceError: x is not defined at index.js:10:15"
}
}
}),
HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({
focused_pane_workspace: "web-app",
focused_pane_tab: "tests",
focused_pane_id: "p3"
})
}
},
{
name: "Case 4: Long Status Truncation",
env: {
HERDR_SESSION: "heavy-work",
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
event: "pane.agent_status_changed",
data: {
type: "pane_agent_status_changed",
pane_id: "p4",
workspace_id: "docs-search",
agent_status: "blocked",
agent: "Librarian",
custom_status: "This is a very long error message that will definitely exceed the standard length boundary and trigger the truncation logic implemented in the notify.mjs script to verify it outputs exactly three dots at the end."
}
}),
HERDR_PLUGIN_CONTEXT_JSON: JSON.stringify({
focused_pane_workspace: "docs-search",
focused_pane_tab: "docs",
focused_pane_id: "p4"
})
}
}
];
const exec = promisify(execFile);
console.log("Running deterministic ntfy test cases...");
for (const tc of testCases) {
console.log(`\n--> Running: ${tc.name}`);
try {
execSync("node notify.mjs", {
env: { ...process.env, ...tc.env },
stdio: 'inherit'
test('completion notifications ignore focus, deduplicate, and keep blocked alerts', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'herdr-ntfy-test-'));
const messages = [];
let responseStatus = 200;
const server = http.createServer((req, res) => {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
if (req.method === 'POST') messages.push({ body, headers: req.headers });
res.writeHead(req.method === 'POST' ? responseStatus : 200).end();
});
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${server.address().port}`;
const cli = path.join(dir, 'herdr');
fs.writeFileSync(cli, `#!${process.execPath}\nif (process.env.FAKE_API_FAIL) process.exit(1);\nconsole.log(JSON.stringify({result:{agent:JSON.parse(process.env.FAKE_AGENT)}}));\n`);
fs.chmodSync(cli, 0o700);
const socket = path.join(dir, 'server.sock');
fs.writeFileSync(socket, 'test server incarnation');
const env = {
...process.env,
HERDR_BIN_PATH: cli,
HERDR_SOCKET_PATH: socket,
HERDR_PLUGIN_STATE_DIR: path.join(dir, 'state'),
HERDR_PLUGIN_CONFIG_DIR: path.join(dir, 'config'),
HERDR_NTFY_LOCAL_URL: url,
HERDR_NTFY_EXTERNAL_URL: url,
HERDR_NTFY_TOPIC: 'test-only',
HERDR_NTFY_BEARER_TOKEN: 'test-token',
HERDR_NTFY_ENABLED: '1',
HERDR_PLUGIN_CONTEXT_JSON: '{}',
};
const agent = (status, seq = 1, terminal = 'term1') => ({
terminal_id: terminal, pane_id: 'w1:p1', agent_status: status,
state_change_seq: seq, completion_seq: ['idle', 'done'].includes(status) ? seq : null,
});
async function run(status, live = agent(status), overrides = {}) {
return exec(process.execPath, ['notify.mjs'], {
cwd: import.meta.dirname,
env: {
...env,
HERDR_PLUGIN_EVENT_JSON: JSON.stringify({
event: 'pane_agent_status_changed',
data: { pane_id: 'w1:p1', agent_status: status, agent: 'Pi' },
}),
FAKE_AGENT: JSON.stringify(live), ...overrides,
}, timeout: 15000,
});
console.log(" Completed successfully.");
} catch (err) {
console.error(` Failed: ${err.message}`);
}
}
console.log("\nAll test cases executed.");
try {
await run('idle');
assert.equal(messages.length, 1, 'focused completion must notify');
assert.equal(messages[0].headers.title, 'Herdr agent done');
assert.match(messages[0].body, /Pi is done/);
await run('idle');
await run('done');
assert.equal(messages.length, 1, 'same completion must not notify twice');
await run('done', agent('done', 2));
await run('idle', agent('idle', 2));
assert.equal(messages.length, 2, 'background completion and read acknowledgement');
await run('idle', { ...agent('idle', 3), completion_seq: null });
await run('working', agent('working', 4));
await run('idle', agent('working', 4));
assert.equal(messages.length, 2, 'startup idle and stale completion must not notify');
await run('blocked', agent('blocked', 5));
await run('blocked', agent('blocked', 5));
assert.equal(messages.length, 3, 'blocked metadata updates must not repeat alert');
assert.equal(messages[2].headers.title, 'Herdr agent needs action');
await Promise.all(Array.from({ length: 8 }, () => run('idle', agent('idle', 6))));
assert.equal(messages.length, 4, 'concurrent hooks claim a completion once');
await run('idle', agent('idle', 6, 'term2'));
assert.equal(messages.length, 5, 'terminals are independent');
await run('idle', agent('idle', 7), { HERDR_NTFY_ENABLED: '0' });
assert.equal(messages.length, 5, 'disabled plugin does not send');
const failed = await run('idle', agent('idle', 7), { FAKE_API_FAIL: '1' });
assert.match(failed.stderr, /error/i);
assert.equal(messages.length, 5, 'API failure must not guess completion');
responseStatus = 503;
await run('idle', agent('idle', 7));
const failures = messages.length;
responseStatus = 200;
await run('idle', agent('idle', 7));
assert.equal(messages.length, failures + 1, 'failed delivery releases claim for retry');
const socket2 = path.join(dir, 'another-server.sock');
fs.writeFileSync(socket2, 'another server');
await run('idle', agent('idle', 7), { HERDR_SOCKET_PATH: socket2 });
assert.equal(messages.length, failures + 2, 'servers are independent');
await run('idle', agent('idle', 8), { HERDR_PLUGIN_EVENT_JSON: '{' });
assert.equal(messages.length, failures + 2, 'invalid JSON does not send');
} finally {
await new Promise(resolve => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});