/** * Hermes Cron/Task Scanner * * Read-only bridge that discovers Hermes Agent's scheduled cron jobs from: * - ~/.hermes/cron/jobs.json — Scheduled task definitions * - ~/.hermes/cron/output/{job_id}/ — Execution output files * * Follows the same throttled-scan pattern as claude-tasks.ts. */ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' import { join } from 'node:path' import { config } from './config' import { logger } from './logger' export interface HermesCronJob { id: string prompt: string schedule: string enabled: boolean lastRunAt: string | null lastOutput: string | null createdAt: string | null } export interface HermesTaskScanResult { cronJobs: HermesCronJob[] } function getHermesCronDir(): string { return join(config.homeDir, '.hermes', 'cron') } function peekLatestOutput(cronDir: string, jobId: string): { lastRunAt: string | null; lastOutput: string | null } { const outputDir = join(cronDir, 'output', jobId) try { if (!existsSync(outputDir) || !statSync(outputDir).isDirectory()) { return { lastRunAt: null, lastOutput: null } } const files = readdirSync(outputDir) .filter(f => f.endsWith('.md')) .sort() .reverse() if (files.length === 0) return { lastRunAt: null, lastOutput: null } const latestFile = files[0] // Filename is typically a timestamp like 2025-01-15T10-30-00.md const timestamp = latestFile.replace(/\.md$/, '').replace(/-/g, (m, i) => { // Convert filename back to ISO-ish timestamp return i > 9 ? ':' : m }) const filePath = join(outputDir, latestFile) let content: string | null = null try { const raw = readFileSync(filePath, 'utf-8') content = raw.slice(0, 500) } catch { /* ignore */ } return { lastRunAt: timestamp || null, lastOutput: content, } } catch { return { lastRunAt: null, lastOutput: null } } } function scanCronJobs(): HermesCronJob[] { const cronDir = getHermesCronDir() const jobsFile = join(cronDir, 'jobs.json') if (!existsSync(jobsFile)) return [] try { const raw = readFileSync(jobsFile, 'utf-8') const jobs = JSON.parse(raw) if (!Array.isArray(jobs)) return [] return jobs.map((job: any) => { const id = job.id || job.name || 'unknown' const { lastRunAt, lastOutput } = peekLatestOutput(cronDir, id) return { id, prompt: job.prompt || job.command || job.description || '', schedule: job.schedule || job.cron || job.interval || '', enabled: job.enabled !== false, lastRunAt: job.last_run_at || lastRunAt, lastOutput, createdAt: job.created_at || null, } }) } catch (err) { logger.warn({ err }, 'Failed to parse Hermes cron jobs') return [] } } // Throttle full disk scans let lastScanAt = 0 let cachedResult: HermesTaskScanResult = { cronJobs: [] } const SCAN_THROTTLE_MS = 30_000 export function getHermesTasks(force = false): HermesTaskScanResult { const now = Date.now() if (!force && lastScanAt > 0 && (now - lastScanAt) < SCAN_THROTTLE_MS) { return cachedResult } try { cachedResult = { cronJobs: scanCronJobs() } lastScanAt = now } catch (err) { logger.warn({ err }, 'Hermes task scan failed') } return cachedResult }