feat: herdr ntfy notifications plugin with dynamic emojis and local routing

This commit is contained in:
olegxxl 2026-06-23 10:18:13 +03:00
commit 8945030287
7 changed files with 498 additions and 0 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# Herdr ntfy notify configuration template
HERDR_NTFY_EXTERNAL_URL=http://localhost:10081
HERDR_NTFY_LOCAL_URL=http://127.0.0.1:10081
HERDR_NTFY_TOPIC=herdr-alerts
HERDR_NTFY_ENABLED=1
# Optional token for authenticated ntfy topics:
HERDR_NTFY_BEARER_TOKEN=

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.env
.env.local
node_modules/
.DS_Store

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 olegxxl
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

59
README.md Normal file
View File

@ -0,0 +1,59 @@
# 📣 herdr-ntfy-notify
> 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`).
---
## ⚡ The Cool Stuff (Features)
- **🎭 Situation-Aware Emojis**: ntfy renders multi-emoji tags based on who is talking and what they did:
- **Codex** gets a scroll 📜 | **OMP / Pi** get tools 🛠️ | **Hermes** gets a chat balloon 💬 | **Librarian** gets books 📚.
- Success gets a check ✅ | Questions get a question mark ❓ | Errors get a cross ❌.
- **🚀 Sub-millisecond Local Bypass**: Auto-detects if a local ntfy server is up (e.g. `localhost:10081`) and fires alerts instantly via `curl` bypassing TLS/network lag. Falls back to Tailscale smoothly.
- **📍 Location Pings**: Pushes the exact workspace, tab, and pane ID (e.g. `Loc: app · test (pane p3)`), so you know exactly where to run `herdr` to take over.
---
## ⚙️ Quick Start
### 1. Link the Plugin
Tell your local Herdr daemon about this plugin:
```bash
herdr plugin link /opt/herdr-ntfy-notify
```
### 2. Configure
Copy the configuration template and edit it:
```bash
CONFIG_DIR="$(herdr plugin config-dir local.agent-ntfy-notify)"
cp /opt/herdr-ntfy-notify/.env.example "$CONFIG_DIR/.env"
```
Configure your `.env` variables:
```dotenv
HERDR_NTFY_EXTERNAL_URL=http://localhost:10081
HERDR_NTFY_LOCAL_URL=http://127.0.0.1:10081
HERDR_NTFY_TOPIC=herdr-alerts
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
```
---
## 🔬 Deterministic Verification
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
```
## 📄 License
MIT License. See [LICENSE](LICENSE) for details.

15
herdr-plugin.toml Normal file
View File

@ -0,0 +1,15 @@
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."
platforms = ["linux", "macos", "windows"]
[[actions]]
id = "test"
title = "Send test ntfy notification"
command = ["node", "notify.mjs", "--test"]
[[events]]
on = "pane.agent_status_changed"
command = ["node", "notify.mjs"]

283
notify.mjs Normal file
View File

@ -0,0 +1,283 @@
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
// Helper to parse .env file
function loadEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
const env = {};
const content = fs.readFileSync(filePath, 'utf-8');
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const index = trimmed.indexOf('=');
if (index === -1) continue;
const key = trimmed.slice(0, index).trim();
let value = trimmed.slice(index + 1).trim();
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
} else if (value.startsWith("'") && value.endsWith("'")) {
value = value.slice(1, -1);
}
env[key] = value;
}
return env;
}
// 1. Load configuration
const envFromConfigDir = process.env.HERDR_PLUGIN_CONFIG_DIR
? loadEnv(path.join(process.env.HERDR_PLUGIN_CONFIG_DIR, '.env'))
: {};
const envFromFallback = loadEnv(path.join('/opt/herdr-ntfy-notify', '.env'));
const config = {
NTFY_EXTERNAL_URL: process.env.HERDR_NTFY_EXTERNAL_URL || process.env.NTFY_EXTERNAL_URL || envFromConfigDir.HERDR_NTFY_EXTERNAL_URL || envFromConfigDir.NTFY_EXTERNAL_URL || envFromFallback.HERDR_NTFY_EXTERNAL_URL || envFromFallback.NTFY_EXTERNAL_URL || 'http://localhost:10081',
NTFY_LOCAL_URL: process.env.HERDR_NTFY_LOCAL_URL || process.env.NTFY_LOCAL_URL || envFromConfigDir.HERDR_NTFY_LOCAL_URL || envFromConfigDir.NTFY_LOCAL_URL || envFromFallback.HERDR_NTFY_LOCAL_URL || envFromFallback.NTFY_LOCAL_URL || 'http://127.0.0.1:10081',
NTFY_TOPIC: process.env.HERDR_NTFY_TOPIC || process.env.NTFY_TOPIC || envFromConfigDir.HERDR_NTFY_TOPIC || envFromConfigDir.NTFY_TOPIC || envFromFallback.HERDR_NTFY_TOPIC || envFromFallback.NTFY_TOPIC || 'herdr-alerts',
HERDR_NTFY_ENABLED: process.env.HERDR_NTFY_ENABLED || envFromConfigDir.HERDR_NTFY_ENABLED || envFromFallback.HERDR_NTFY_ENABLED || '1',
NTFY_BEARER_TOKEN: process.env.HERDR_NTFY_BEARER_TOKEN || process.env.NTFY_BEARER_TOKEN || envFromConfigDir.HERDR_NTFY_BEARER_TOKEN || envFromConfigDir.NTFY_BEARER_TOKEN || envFromFallback.HERDR_NTFY_BEARER_TOKEN || envFromFallback.NTFY_BEARER_TOKEN || '',
NTFY_PRIORITY_DONE: process.env.HERDR_NTFY_PRIORITY_DONE || envFromConfigDir.HERDR_NTFY_PRIORITY_DONE || envFromFallback.HERDR_NTFY_PRIORITY_DONE || 'default',
NTFY_PRIORITY_BLOCKED: process.env.HERDR_NTFY_PRIORITY_BLOCKED || envFromConfigDir.HERDR_NTFY_PRIORITY_BLOCKED || envFromFallback.HERDR_NTFY_PRIORITY_BLOCKED || 'high',
NTFY_TAGS_DONE: process.env.HERDR_NTFY_TAGS_DONE || envFromConfigDir.NTFY_TAGS_DONE || envFromFallback.NTFY_TAGS_DONE || '',
NTFY_TAGS_BLOCKED: process.env.HERDR_NTFY_TAGS_BLOCKED || envFromConfigDir.NTFY_TAGS_BLOCKED || envFromFallback.NTFY_TAGS_BLOCKED || '',
};
const isEnabled = !['0', 'false', 'no', 'off'].includes(config.HERDR_NTFY_ENABLED.toString().toLowerCase());
// Auto-endpoint detection: try local ntfy server first if configured
async function resolveNtfyUrl() {
if (!config.NTFY_LOCAL_URL) return config.NTFY_EXTERNAL_URL;
try {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), 800); // 800ms quick check timeout
const res = await fetch(`${config.NTFY_LOCAL_URL}/`, { signal: controller.signal });
clearTimeout(id);
if (res.ok || res.status === 404 || res.status === 405) {
return config.NTFY_LOCAL_URL;
}
} catch (err) {
// Local server unreachable, fallback to default base URL
}
return config.NTFY_EXTERNAL_URL;
}
// 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'];
for (const [key, value] of Object.entries(headers)) {
args.push('-H', `${key}: ${value}`);
}
args.push('-d', body);
args.push(url);
// Escape args safely for exec
const cmd = args.map(arg => `'${arg.replace(/'/g, "'\\''")}'`).join(' ');
const stdout = execSync(cmd, { stdio: 'pipe' }).toString().trim();
const statusCode = parseInt(stdout, 10);
return statusCode >= 200 && statusCode < 300;
} catch (err) {
return false;
}
}
// Get dynamic, situation-specific tags/emojis
function getTags(status, agent, text) {
const tagsSet = new Set();
// 1. Configured custom tags prefix
const customTags = status === 'done' ? config.NTFY_TAGS_DONE : config.NTFY_TAGS_BLOCKED;
if (customTags) {
for (const t of customTags.split(',')) {
const trimmed = t.trim();
if (trimmed) tagsSet.add(trimmed);
}
}
// 2. Status-specific base emoji
if (status === 'done') {
tagsSet.add('white_check_mark');
} else if (status === 'blocked') {
const textLower = text.toLowerCase();
if (text.includes('?') || textLower.includes('prompt') || textLower.includes('choose') || textLower.includes('ask')) {
tagsSet.add('question');
} else if (textLower.includes('error') || textLower.includes('fail') || textLower.includes('exception')) {
tagsSet.add('x');
} else {
tagsSet.add('warning');
}
}
// 3. Agent-specific emoji
const agentLower = agent.toLowerCase();
if (agentLower.includes('codex')) {
tagsSet.add('scroll');
} else if (agentLower.includes('hermes')) {
tagsSet.add('speech_balloon');
} else if (agentLower.includes('omp') || agentLower.includes('pi') || agentLower.includes('task')) {
tagsSet.add('tools');
} else if (agentLower.includes('librarian')) {
tagsSet.add('books');
}
// 4. Test run indicator
if (text.toLowerCase().includes('test')) {
tagsSet.add('test_tube');
}
return Array.from(tagsSet).join(',');
}
async function run() {
const args = process.argv.slice(2);
const isTest = args.includes('--test');
if (isTest) {
if (!isEnabled) {
console.log("ntfy plugin is disabled by config.");
return;
}
await sendNotification({
title: 'Herdr ntfy test',
body: 'Plugin can publish to ntfy.',
priority: 'default',
tags: 'bell,test_tube',
});
return;
}
if (!isEnabled) {
return;
}
// Parse event and context from env
let event = {};
let context = {};
if (process.env.HERDR_PLUGIN_EVENT_JSON) {
try {
event = JSON.parse(process.env.HERDR_PLUGIN_EVENT_JSON);
} catch (err) {
console.error("Invalid HERDR_PLUGIN_EVENT_JSON:", err.message);
return;
}
}
if (process.env.HERDR_PLUGIN_CONTEXT_JSON) {
try {
context = JSON.parse(process.env.HERDR_PLUGIN_CONTEXT_JSON);
} catch (err) {
console.error("Invalid HERDR_PLUGIN_CONTEXT_JSON:", err.message);
return;
}
}
const status = (
event?.data?.agent_status ||
context?.focused_pane_status ||
context?.agent_status ||
context?.status ||
''
).toLowerCase();
if (status !== 'done' && status !== 'blocked') {
return;
}
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 || '';
const tab = event?.data?.tab || context?.focused_pane_tab || context?.tab || '';
const paneId = event?.data?.pane_id || context?.focused_pane_id || '';
const customStatus = event?.data?.custom_status || context?.custom_status || '';
const title = event?.data?.title || context?.title || '';
const error = event?.data?.state_labels?.error || '';
const task = event?.data?.state_labels?.task || '';
const session = process.env.HERDR_SESSION || '';
// Construct message header / description
let header = `${agent} is ${status}`;
if (customStatus) {
header += `: ${customStatus}`;
} else if (error) {
header += `: ${error}`;
} else if (task) {
header += `: ${task}`;
} else if (title) {
header += ` (${title})`;
}
if (header.length > 200) {
header = header.slice(0, 197) + '...';
}
let body = header;
const details = [];
if (session) {
details.push(`Session: ${session}`);
}
if (workspace) {
let loc = `Loc: ${workspace}`;
if (tab) loc += ` · ${tab}`;
if (paneId) loc += ` (pane ${paneId})`;
details.push(loc);
}
if (details.length > 0) {
body += `\n${details.join('\n')}`;
}
const notification = {
title: status === 'done' ? 'Herdr agent done' : 'Herdr agent needs action',
body,
priority: status === 'done' ? config.NTFY_PRIORITY_DONE : config.NTFY_PRIORITY_BLOCKED,
tags: getTags(status, agent, body),
};
await sendNotification(notification);
}
async function sendNotification({ title, body, priority, tags }) {
const baseUrl = await resolveNtfyUrl();
const url = `${baseUrl}/${encodeURIComponent(config.NTFY_TOPIC)}`;
const headers = {
'Title': title,
'Priority': priority,
'Tags': tags,
'Content-Type': 'text/plain; charset=utf-8',
};
if (config.NTFY_BEARER_TOKEN) {
if (config.NTFY_BEARER_TOKEN.includes(':')) {
const encoded = Buffer.from(config.NTFY_BEARER_TOKEN).toString('base64');
headers['Authorization'] = `Basic ${encoded}`;
} else {
headers['Authorization'] = `Bearer ${config.NTFY_BEARER_TOKEN}`;
}
}
// 1. Try sending with curl (open-source way: if local curl is available)
const curlSuccess = publishWithCurl(url, headers, body);
if (curlSuccess) {
return;
}
// 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);
}
}
run().catch(err => {
console.error("Unhandled error in notify.mjs:", err);
process.exit(0); // non-fatal to herdr
});

109
test_cases.js Normal file
View File

@ -0,0 +1,109 @@
import { execSync } from 'child_process';
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"
})
}
}
];
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'
});
console.log(" Completed successfully.");
} catch (err) {
console.error(` Failed: ${err.message}`);
}
}
console.log("\nAll test cases executed.");