import fs from 'fs'; import path from 'path'; import { execSync, execFileSync } from 'child_process'; import { createHash } from 'node:crypto'; // 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', '--max-time', '10', '-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', timeout: 12000 }).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; } } 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; 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 || ''; 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), }; 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 }) { 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 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 => { console.error("Unhandled error in notify.mjs:", err); process.exit(0); // non-fatal to herdr });