commit 8945030287785e45f33bf78990a671c3020b0d5c Author: olegxxl Date: Tue Jun 23 10:18:13 2026 +0300 feat: herdr ntfy notifications plugin with dynamic emojis and local routing diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d7dede --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80abaf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.env.local +node_modules/ +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7cbe872 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b6c8fa2 --- /dev/null +++ b/README.md @@ -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. diff --git a/herdr-plugin.toml b/herdr-plugin.toml new file mode 100644 index 0000000..5cee14b --- /dev/null +++ b/herdr-plugin.toml @@ -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"] diff --git a/notify.mjs b/notify.mjs new file mode 100644 index 0000000..9c5c4ca --- /dev/null +++ b/notify.mjs @@ -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 +}); diff --git a/test_cases.js b/test_cases.js new file mode 100644 index 0000000..2cc0a8a --- /dev/null +++ b/test_cases.js @@ -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.");