314 lines
12 KiB
JavaScript
314 lines
12 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execFileSync } from 'child_process';
|
|
import { devNull } from 'node:os';
|
|
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 {
|
|
// 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 = ['-s', '--max-time', '10', '-o', devNull, '-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);
|
|
|
|
const stdout = execFileSync('curl', args, { stdio: 'pipe', timeout: 12000 }).toString().trim();
|
|
const statusCode = parseInt(stdout, 10);
|
|
return statusCode >= 200 && statusCode < 300;
|
|
} catch {
|
|
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');
|
|
// Terminal IDs are unique across restores; Windows endpoints are names, not files to stat.
|
|
const key = createHash('sha256').update(JSON.stringify([
|
|
socket,
|
|
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
|
|
});
|