diff --git a/package.json b/package.json index c9bb563..7a00131 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { - "name": "harborforge-openclaw-plugin", - "version": "0.1.0", - "description": "OpenClaw plugin for HarborForge Monitor - streams server telemetry", + "name": "harbor-forge-openclaw-plugin", + "version": "0.2.0", + "description": "OpenClaw plugin for HarborForge - project management, monitoring, and CLI integration", "type": "module", "scripts": { "build": "cd plugin && npm run build", "install": "node scripts/install.mjs", + "install-cli": "node scripts/install.mjs --install-cli", "uninstall": "node scripts/install.mjs --uninstall" }, - "keywords": ["openclaw", "plugin", "monitoring", "harborforge"], + "keywords": ["openclaw", "plugin", "monitoring", "harborforge", "harbor-forge"], "license": "MIT", "engines": { "node": ">=18.0.0" diff --git a/plugin/index.ts b/plugin/index.ts index 18d1577..5fc2481 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1,11 +1,14 @@ /** - * HarborForge Monitor Plugin for OpenClaw + * HarborForge Plugin for OpenClaw * - * Manages sidecar lifecycle and provides monitor-related tools. + * Provides monitor-related tools and exposes OpenClaw metadata + * for the HarborForge Monitor bridge (via monitor_port). + * + * Sidecar architecture has been removed. Telemetry data is now + * served directly by the plugin when Monitor queries via the + * local monitor_port communication path. */ -import { spawn } from 'child_process'; -import { join } from 'path'; -import { existsSync } from 'fs'; +import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; import { getLivePluginConfig, type HarborForgeMonitorConfig } from './core/live-config'; interface PluginAPI { @@ -23,14 +26,14 @@ interface PluginAPI { } export default { - id: 'harborforge-monitor', - name: 'HarborForge Monitor', + id: 'harbor-forge', + name: 'HarborForge', register(api: PluginAPI) { const logger = api.logger || { - info: (...args: any[]) => console.log('[HF-Monitor]', ...args), - error: (...args: any[]) => console.error('[HF-Monitor]', ...args), - debug: (...args: any[]) => console.debug('[HF-Monitor]', ...args), - warn: (...args: any[]) => console.warn('[HF-Monitor]', ...args), + info: (...args: any[]) => console.log('[HarborForge]', ...args), + error: (...args: any[]) => console.error('[HarborForge]', ...args), + debug: (...args: any[]) => console.debug('[HarborForge]', ...args), + warn: (...args: any[]) => console.warn('[HarborForge]', ...args), }; const baseConfig: HarborForgeMonitorConfig = { @@ -43,122 +46,53 @@ export default { ...(api.pluginConfig || {}), }; - const serverPath = join(__dirname, 'server', 'telemetry.mjs'); - let sidecar: ReturnType | null = null; - function resolveConfig() { return getLivePluginConfig(api, baseConfig); } - function startSidecar() { + /** + * Collect current system telemetry snapshot. + * This data is exposed to the Monitor bridge when it queries the plugin. + */ + function collectTelemetry() { const live = resolveConfig(); - const enabled = live.enabled !== false; - - logger.info('HarborForge Monitor plugin config resolved', { - enabled, - hasApiKey: Boolean(live.apiKey), - backendUrl: live.backendUrl ?? null, - identifier: live.identifier ?? null, - }); - - if (!enabled) { - logger.info('HarborForge Monitor plugin disabled'); - return; - } - - if (sidecar) { - logger.debug('Sidecar already running'); - return; - } - - if (!live.apiKey) { - logger.warn('Missing config: apiKey'); - logger.warn('API authentication will fail. Generate apiKey from HarborForge Monitor admin.'); - } - - if (!existsSync(serverPath)) { - logger.error('Telemetry server not found:', serverPath); - return; - } - - logger.info('Starting HarborForge Monitor telemetry server...'); - - const env = { - ...process.env, - HF_MONITOR_BACKEND_URL: live.backendUrl || 'https://monitor.hangman-lab.top', - HF_MONITOR_IDENTIFIER: live.identifier || '', - HF_MONITOR_API_KEY: live.apiKey || '', - HF_MONITOR_REPORT_INTERVAL: String(live.reportIntervalSec || 30), - HF_MONITOR_HTTP_FALLBACK_INTERVAL: String(live.httpFallbackIntervalSec || 60), - HF_MONITOR_LOG_LEVEL: live.logLevel || 'info', - OPENCLAW_PATH: process.env.OPENCLAW_PATH || join(process.env.HOME || '/root', '.openclaw'), - HF_MONITOR_PLUGIN_VERSION: api.version || 'unknown', + const load = loadavg(); + return { + identifier: live.identifier || hostname(), + platform: platform(), + hostname: hostname(), + uptime: uptime(), + memory: { + total: totalmem(), + free: freemem(), + used: totalmem() - freemem(), + usagePercent: ((totalmem() - freemem()) / totalmem()) * 100, + }, + load: { + avg1: load[0], + avg5: load[1], + avg15: load[2], + }, + openclaw: { + version: api.version || 'unknown', + pluginVersion: '0.2.0', + }, + timestamp: new Date().toISOString(), }; - - sidecar = spawn('node', [serverPath], { - env, - detached: false, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - sidecar.stdout?.on('data', (data: Buffer) => { - logger.info('[telemetry]', data.toString().trim()); - }); - - sidecar.stderr?.on('data', (data: Buffer) => { - logger.error('[telemetry]', data.toString().trim()); - }); - - sidecar.on('exit', (code, signal) => { - logger.info(`Telemetry server exited (code: ${code}, signal: ${signal})`); - sidecar = null; - }); - - sidecar.on('error', (err: Error) => { - logger.error('Failed to start telemetry server:', err.message); - sidecar = null; - }); - - logger.info('Telemetry server started with PID:', sidecar.pid); - } - - function stopSidecar() { - if (!sidecar) { - logger.debug('Telemetry server not running'); - return; - } - - logger.info('Stopping HarborForge Monitor telemetry server...'); - sidecar.kill('SIGTERM'); - - const timeout = setTimeout(() => { - if (sidecar && !sidecar.killed) { - logger.warn('Telemetry server did not exit gracefully, forcing kill'); - sidecar.kill('SIGKILL'); - } - }, 5000); - - sidecar.on('exit', () => { - clearTimeout(timeout); - }); } api.on('gateway_start', () => { - logger.info('gateway_start received, starting telemetry server...'); - startSidecar(); + logger.info('HarborForge plugin active'); }); api.on('gateway_stop', () => { - logger.info('gateway_stop received, stopping telemetry server...'); - stopSidecar(); + logger.info('HarborForge plugin stopping'); }); - process.on('SIGTERM', stopSidecar); - process.on('SIGINT', stopSidecar); - + // Tool: plugin status api.registerTool(() => ({ - name: 'harborforge_monitor_status', - description: 'Get HarborForge Monitor plugin status', + name: 'harborforge_status', + description: 'Get HarborForge plugin status and current telemetry snapshot', parameters: { type: 'object', properties: {}, @@ -167,18 +101,31 @@ export default { const live = resolveConfig(); return { enabled: live.enabled !== false, - sidecarRunning: sidecar !== null && sidecar.exitCode === null, - pid: sidecar?.pid || null, config: { backendUrl: live.backendUrl, - identifier: live.identifier || 'auto-detected', + identifier: live.identifier || hostname(), + monitorPort: (live as any).monitorPort || null, reportIntervalSec: live.reportIntervalSec, hasApiKey: Boolean(live.apiKey), }, + telemetry: collectTelemetry(), }; }, })); - logger.info('HarborForge Monitor plugin registered'); + // Tool: telemetry snapshot (for Monitor bridge queries) + api.registerTool(() => ({ + name: 'harborforge_telemetry', + description: 'Get current system telemetry data for HarborForge Monitor', + parameters: { + type: 'object', + properties: {}, + }, + async execute() { + return collectTelemetry(); + }, + })); + + logger.info('HarborForge plugin registered (id: harbor-forge)'); }, }; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index 816481d..cba0d12 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,8 +1,8 @@ { - "id": "harborforge-monitor", - "name": "HarborForge Monitor", - "version": "0.1.0", - "description": "Server monitoring plugin for HarborForge - streams telemetry to Monitor", + "id": "harbor-forge", + "name": "HarborForge", + "version": "0.2.0", + "description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration", "entry": "./index.js", "configSchema": { "type": "object", @@ -11,7 +11,7 @@ "enabled": { "type": "boolean", "default": true, - "description": "Enable the monitor plugin" + "description": "Enable the HarborForge plugin" }, "backendUrl": { "type": "string", @@ -26,6 +26,10 @@ "type": "string", "description": "API Key from HarborForge Monitor admin panel (optional but required for authentication)" }, + "monitorPort": { + "type": "number", + "description": "Local port for communication between HarborForge Monitor and this plugin" + }, "reportIntervalSec": { "type": "number", "default": 30, diff --git a/scripts/install.mjs b/scripts/install.mjs index e343d54..718dabf 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -1,7 +1,13 @@ #!/usr/bin/env node /** - * HarborForge Monitor Plugin Installer v0.1.0 + * HarborForge Plugin Installer v0.2.0 + * + * Changes from v0.1.0: + * - Plugin renamed from harborforge-monitor to harbor-forge + * - Sidecar server removed (telemetry served directly by plugin) + * - Added --install-cli flag for building and installing the hf CLI + * - skills/hf/ only installed when --install-cli is present */ import { execSync } from 'child_process'; @@ -11,8 +17,7 @@ import { copyFileSync, readdirSync, rmSync, - readFileSync, - writeFileSync, + chmodSync, } from 'fs'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -21,9 +26,9 @@ import { homedir, platform } from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = resolve(dirname(__filename), '..'); -const PLUGIN_NAME = 'harborforge-monitor'; +const PLUGIN_NAME = 'harbor-forge'; +const OLD_PLUGIN_NAME = 'harborforge-monitor'; const PLUGIN_SRC_DIR = join(__dirname, 'plugin'); -const SERVER_SRC_DIR = join(__dirname, 'server'); const SKILLS_SRC_DIR = join(__dirname, 'skills'); const args = process.argv.slice(2); @@ -34,6 +39,7 @@ const options = { verbose: args.includes('--verbose') || args.includes('-v'), uninstall: args.includes('--uninstall'), installOnly: args.includes('--install'), + installCli: args.includes('--install-cli'), }; const profileIdx = args.indexOf('--openclaw-profile-path'); @@ -83,19 +89,21 @@ function unsetOpenclawConfig(key) { try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {} } -function copyDir(src, dest) { +function copyDir(src, dest, { exclude = [] } = {}) { mkdirSync(dest, { recursive: true }); for (const entry of readdirSync(src, { withFileTypes: true })) { const s = join(src, entry.name); const d = join(dest, entry.name); if (entry.name === 'node_modules') continue; - entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); + if (exclude.includes(entry.name)) continue; + entry.isDirectory() ? copyDir(s, d, { exclude }) : copyFileSync(s, d); } } function detectEnvironment() { - logStep(1, 5, 'Detecting environment...'); - const env = { platform: platform(), nodeVersion: null }; + const totalSteps = options.installCli ? 6 : 5; + logStep(1, totalSteps, 'Detecting environment...'); + const env = { platform: platform(), nodeVersion: null, goVersion: null }; try { env.nodeVersion = exec('node --version', { silent: true }).trim(); @@ -109,19 +117,34 @@ function detectEnvironment() { } catch { logWarn('openclaw CLI not in PATH'); } + + if (options.installCli) { + try { + env.goVersion = exec('go version', { silent: true }).trim(); + logOk(env.goVersion); + } catch { + logWarn('Go not found (needed for --install-cli)'); + } + } return env; } function checkDeps(env) { - if (options.skipCheck) { logStep(2, 5, 'Skipping dep checks'); return; } - logStep(2, 5, 'Checking dependencies...'); + const totalSteps = options.installCli ? 6 : 5; + if (options.skipCheck) { logStep(2, totalSteps, 'Skipping dep checks'); return; } + logStep(2, totalSteps, 'Checking dependencies...'); let fail = false; if (!env.nodeVersion || parseInt(env.nodeVersion.slice(1)) < 18) { logErr('Node.js 18+ required'); fail = true; } + + if (options.installCli && !env.goVersion) { + logErr('Go is required for --install-cli'); + fail = true; + } if (fail) { log('\nInstall missing deps and retry.', 'red'); @@ -132,7 +155,8 @@ function checkDeps(env) { } async function build() { - logStep(3, 5, 'Building plugin...'); + const totalSteps = options.installCli ? 6 : 5; + logStep(3, totalSteps, 'Building plugin...'); log(' Building TypeScript plugin...', 'blue'); exec('npm install', { cwd: PLUGIN_SRC_DIR, silent: !options.verbose }); @@ -141,11 +165,18 @@ async function build() { } function clearInstallTargets(openclawPath) { + // Remove new plugin dir const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); if (existsSync(destDir)) { rmSync(destDir, { recursive: true, force: true }); logOk(`Removed ${destDir}`); } + // Remove old plugin dir if it exists + const oldDestDir = join(openclawPath, 'plugins', OLD_PLUGIN_NAME); + if (existsSync(oldDestDir)) { + rmSync(oldDestDir, { recursive: true, force: true }); + logOk(`Removed old plugin dir ${oldDestDir}`); + } } function cleanupConfig(openclawPath) { @@ -153,20 +184,31 @@ function cleanupConfig(openclawPath) { try { const allow = getOpenclawConfig('plugins.allow', []); - const idx = allow.indexOf(PLUGIN_NAME); - if (idx !== -1) { - allow.splice(idx, 1); - setOpenclawConfig('plugins.allow', allow); - logOk('Removed from allow list'); + // Remove both old and new names + for (const name of [PLUGIN_NAME, OLD_PLUGIN_NAME]) { + const idx = allow.indexOf(name); + if (idx !== -1) { + allow.splice(idx, 1); + logOk(`Removed ${name} from allow list`); + } } + setOpenclawConfig('plugins.allow', allow); unsetOpenclawConfig(`plugins.entries.${PLUGIN_NAME}`); - logOk('Removed plugin entry'); + unsetOpenclawConfig(`plugins.entries.${OLD_PLUGIN_NAME}`); + logOk('Removed plugin entries'); const paths = getOpenclawConfig('plugins.load.paths', []); - const pidx = paths.indexOf(destDir); - if (pidx !== -1) { - paths.splice(pidx, 1); + const oldDestDir = join(openclawPath, 'plugins', OLD_PLUGIN_NAME); + let changed = false; + for (const p of [destDir, oldDestDir]) { + const pidx = paths.indexOf(p); + if (pidx !== -1) { + paths.splice(pidx, 1); + changed = true; + } + } + if (changed) { setOpenclawConfig('plugins.load.paths', paths); logOk('Removed from load paths'); } @@ -176,8 +218,9 @@ function cleanupConfig(openclawPath) { } async function install() { - if (options.buildOnly) { logStep(4, 5, 'Skipping install (--build-only)'); return null; } - logStep(4, 5, 'Installing...'); + const totalSteps = options.installCli ? 6 : 5; + if (options.buildOnly) { logStep(4, totalSteps, 'Skipping install (--build-only)'); return null; } + logStep(4, totalSteps, 'Installing...'); const openclawPath = resolveOpenclawPath(); const pluginsDir = join(openclawPath, 'plugins'); @@ -186,29 +229,34 @@ async function install() { log(` OpenClaw path: ${openclawPath}`, 'blue'); if (existsSync(destDir)) { - logWarn('Existing install detected, uninstalling before install...'); + logWarn('Existing install detected, cleaning up...'); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); } - if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true }); + // Clean up old plugin name if present + const oldDestDir = join(pluginsDir, OLD_PLUGIN_NAME); + if (existsSync(oldDestDir)) { + logWarn('Old plugin (harborforge-monitor) detected, removing...'); + rmSync(oldDestDir, { recursive: true, force: true }); + cleanupConfig(openclawPath); + } - // Copy compiled plugin + // Copy compiled plugin (no server directory — sidecar removed) mkdirSync(destDir, { recursive: true }); copyDir(PLUGIN_SRC_DIR, destDir); + logOk(`Plugin files → ${destDir}`); - // Copy telemetry server - const serverDestDir = join(destDir, 'server'); - mkdirSync(serverDestDir, { recursive: true }); - copyDir(SERVER_SRC_DIR, serverDestDir); - logOk(`Server files → ${serverDestDir}`); - - // Copy skills + // Copy skills (exclude hf/ unless --install-cli) if (existsSync(SKILLS_SRC_DIR)) { const skillsDestDir = join(openclawPath, 'skills'); - mkdirSync(skillsDestDir, { recursive: true }); - copyDir(SKILLS_SRC_DIR, skillsDestDir); - logOk(`Skills → ${skillsDestDir}`); + const excludeSkills = options.installCli ? [] : ['hf']; + copyDir(SKILLS_SRC_DIR, skillsDestDir, { exclude: excludeSkills }); + if (options.installCli) { + logOk(`Skills (including hf) → ${skillsDestDir}`); + } else { + logOk(`Skills (hf skipped, use --install-cli) → ${skillsDestDir}`); + } } // Install runtime deps @@ -218,9 +266,51 @@ async function install() { return { destDir }; } +async function installCli() { + if (!options.installCli) return; + const totalSteps = 6; + logStep(5, totalSteps, 'Building and installing hf CLI...'); + + const openclawPath = resolveOpenclawPath(); + const binDir = join(openclawPath, 'bin'); + mkdirSync(binDir, { recursive: true }); + + // Find CLI source — look for HarborForge.Cli relative to project root + const projectRoot = resolve(__dirname, '..'); + const cliDir = join(projectRoot, 'HarborForge.Cli'); + + if (!existsSync(cliDir)) { + // Try parent directory (monorepo layout) + const monoCliDir = resolve(projectRoot, '..', 'HarborForge.Cli'); + if (!existsSync(monoCliDir)) { + logErr(`Cannot find HarborForge.Cli at ${cliDir} or ${monoCliDir}`); + logWarn('Skipping CLI installation'); + return; + } + } + + const effectiveCliDir = existsSync(cliDir) + ? cliDir + : resolve(projectRoot, '..', 'HarborForge.Cli'); + + log(` Building hf from ${effectiveCliDir}...`, 'blue'); + + try { + const hfBinary = join(binDir, 'hf'); + exec(`go build -o ${hfBinary} ./cmd/hf`, { cwd: effectiveCliDir, silent: !options.verbose }); + chmodSync(hfBinary, 0o755); + logOk(`hf binary → ${hfBinary}`); + } catch (err) { + logErr(`Failed to build hf CLI: ${err.message}`); + logWarn('CLI installation failed, plugin still installed'); + } +} + async function configure() { - if (options.buildOnly) { logStep(5, 5, 'Skipping config'); return; } - logStep(5, 5, 'Configuring OpenClaw...'); + const totalSteps = options.installCli ? 6 : 5; + const step = options.installCli ? 6 : 5; + if (options.buildOnly) { logStep(step, totalSteps, 'Skipping config'); return; } + logStep(step, totalSteps, 'Configuring OpenClaw...'); const openclawPath = resolveOpenclawPath(); const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); @@ -240,8 +330,7 @@ async function configure() { } logOk(`plugins.allow includes ${PLUGIN_NAME}`); - // Note: apiKey must be configured manually by user - logOk('Plugin configured (remember to set apiKey in plugins.entries.harborforge-monitor.config)'); + logOk('Plugin configured (remember to set apiKey in plugins.entries.harbor-forge.config)'); } catch (err) { logWarn(`Config failed: ${err.message}`); @@ -249,11 +338,12 @@ async function configure() { } function summary() { - logStep(5, 5, 'Done!'); + const totalSteps = options.installCli ? 6 : 5; + logStep(totalSteps, totalSteps, 'Done!'); console.log(''); - log('╔══════════════════════════════════════════════╗', 'cyan'); - log('║ HarborForge Monitor v0.1.0 Install Complete ║', 'cyan'); - log('╚══════════════════════════════════════════════╝', 'cyan'); + log('╔════════════════════════════════════════════╗', 'cyan'); + log('║ HarborForge v0.2.0 Install Complete ║', 'cyan'); + log('╚════════════════════════════════════════════╝', 'cyan'); if (options.buildOnly) { log('\nBuild-only — plugin not installed.', 'yellow'); @@ -263,11 +353,11 @@ function summary() { console.log(''); log('Next steps:', 'blue'); log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan'); - log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harborforge-monitor.config:', 'cyan'); + log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harbor-forge.config:', 'cyan'); log(' {', 'cyan'); log(' "plugins": {', 'cyan'); log(' "entries": {', 'cyan'); - log(' "harborforge-monitor": {', 'cyan'); + log(' "harbor-forge": {', 'cyan'); log(' "enabled": true,', 'cyan'); log(' "config": {', 'cyan'); log(' "enabled": true,', 'cyan'); @@ -278,22 +368,36 @@ function summary() { log(' }', 'cyan'); log(' }', 'cyan'); log(' 3. openclaw gateway restart', 'cyan'); + + if (options.installCli) { + console.log(''); + log(' hf CLI installed to ~/.openclaw/bin/hf', 'green'); + log(' Ensure ~/.openclaw/bin is in your PATH', 'cyan'); + } console.log(''); } async function uninstall() { - log('Uninstalling HarborForge Monitor...', 'cyan'); + log('Uninstalling HarborForge...', 'cyan'); const openclawPath = resolveOpenclawPath(); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); + + // Remove CLI binary if present + const hfBinary = join(openclawPath, 'bin', 'hf'); + if (existsSync(hfBinary)) { + rmSync(hfBinary, { force: true }); + logOk('Removed hf CLI binary'); + } + log('\nRun: openclaw gateway restart', 'yellow'); } async function main() { console.log(''); - log('╔══════════════════════════════════════════════╗', 'cyan'); - log('║ HarborForge Monitor Plugin Installer v0.1.0 ║', 'cyan'); - log('╚══════════════════════════════════════════════╝', 'cyan'); + log('╔════════════════════════════════════════════╗', 'cyan'); + log('║ HarborForge Plugin Installer v0.2.0 ║', 'cyan'); + log('╚════════════════════════════════════════════╝', 'cyan'); console.log(''); try { @@ -309,6 +413,9 @@ async function main() { if (!options.buildOnly) { await install(); + if (options.installCli) { + await installCli(); + } await configure(); } diff --git a/server/telemetry.mjs b/server/telemetry.mjs deleted file mode 100644 index fcb6e6d..0000000 --- a/server/telemetry.mjs +++ /dev/null @@ -1,380 +0,0 @@ -/** - * HarborForge Monitor Telemetry Server - * - * Runs as separate process from Gateway. - * Collects system metrics and OpenClaw status, sends to Monitor. - */ -import { readFile, access, readdir } from 'fs/promises'; -import { constants } from 'fs'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { platform, hostname, freemem, totalmem, uptime, loadavg } from 'os'; - -const execAsync = promisify(exec); - -// Config from environment (set by plugin) -const openclawPath = process.env.OPENCLAW_PATH || `${process.env.HOME}/.openclaw`; -const CONFIG = { - backendUrl: process.env.HF_MONITOR_BACKEND_URL || 'https://monitor.hangman-lab.top', - identifier: process.env.HF_MONITOR_IDENTIFIER || hostname(), - apiKey: process.env.HF_MONITOR_API_KEY, - reportIntervalSec: parseInt(process.env.HF_MONITOR_REPORT_INTERVAL || '30', 10), - httpFallbackIntervalSec: parseInt(process.env.HF_MONITOR_HTTP_FALLBACK_INTERVAL || '60', 10), - logLevel: process.env.HF_MONITOR_LOG_LEVEL || 'info', - openclawPath, - pluginVersion: process.env.HF_MONITOR_PLUGIN_VERSION || 'unknown', - cachePath: process.env.HF_MONITOR_CACHE_PATH || `${openclawPath}/telemetry_cache.json`, - maxCacheSize: parseInt(process.env.HF_MONITOR_MAX_CACHE_SIZE || '100', 10), -}; - -// Logging -const log = { - debug: (...args) => CONFIG.logLevel === 'debug' && console.log('[DEBUG]', ...args), - info: (...args) => ['debug', 'info'].includes(CONFIG.logLevel) && console.log('[INFO]', ...args), - warn: (...args) => console.log('[WARN]', ...args), - error: (...args) => console.error('[ERROR]', ...args), -}; - -// State -let wsConnection = null; -let lastSuccessfulSend = null; -let consecutiveFailures = 0; -let isShuttingDown = false; -let cachedOpenclawVersion = null; - -/** - * Collect system metrics - */ -async function collectSystemMetrics() { - try { - const cpuUsage = await getCpuUsage(); - const memTotal = totalmem(); - const memFree = freemem(); - const memUsed = memTotal - memFree; - const diskInfo = await getDiskUsage(); - const loadAvg = platform() !== 'win32' ? loadavg() : [0, 0, 0]; - - return { - cpu_pct: cpuUsage, - mem_pct: Math.round((memUsed / memTotal) * 100 * 10) / 10, - mem_used_mb: Math.round(memUsed / 1024 / 1024), - mem_total_mb: Math.round(memTotal / 1024 / 1024), - disk_pct: diskInfo.usedPct, - disk_used_gb: Math.round(diskInfo.usedGB * 10) / 10, - disk_total_gb: Math.round(diskInfo.totalGB * 10) / 10, - swap_pct: diskInfo.swapUsedPct || 0, - uptime_seconds: Math.floor(uptime()), - load_avg: [ - Math.round(loadAvg[0] * 100) / 100, - Math.round(loadAvg[1] * 100) / 100, - Math.round(loadAvg[2] * 100) / 100, - ], - platform: platform(), - hostname: hostname(), - }; - } catch (err) { - log.error('Failed to collect system metrics:', err.message); - return {}; - } -} - -/** - * Get CPU usage percentage - */ -async function getCpuUsage() { - try { - if (platform() === 'linux') { - const { stdout } = await execAsync("top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | cut -d'%' -f1"); - const usage = parseFloat(stdout.trim()); - return isNaN(usage) ? 0 : Math.round(usage * 10) / 10; - } else if (platform() === 'darwin') { - const { stdout } = await execAsync("top -l 1 | grep 'CPU usage' | awk '{print $3}' | cut -d'%' -f1"); - const usage = parseFloat(stdout.trim()); - return isNaN(usage) ? 0 : Math.round(usage * 10) / 10; - } - } catch { - try { - const stat = await readFile('/proc/stat', 'utf8'); - const cpuLine = stat.split('\n')[0]; - const parts = cpuLine.split(/\s+/).slice(1).map(Number); - const idle = parts[3]; - const total = parts.reduce((a, b) => a + b, 0); - const usage = ((total - idle) / total) * 100; - return Math.round(usage * 10) / 10; - } catch { - return 0; - } - } - return 0; -} - -/** - * Get disk usage - */ -async function getDiskUsage() { - try { - if (platform() === 'linux' || platform() === 'darwin') { - const { stdout } = await execAsync("df -h / | tail -1 | awk '{print $2,$3,$5}'"); - const [total, used, pct] = stdout.trim().split(/\s+/); - return { - totalGB: parseSizeToGB(total), - usedGB: parseSizeToGB(used), - usedPct: parseInt(pct.replace('%', ''), 10), - }; - } - } catch (err) { - log.debug('Failed to get disk usage:', err.message); - } - return { totalGB: 0, usedGB: 0, usedPct: 0 }; -} - -function parseSizeToGB(size) { - const num = parseFloat(size); - if (size.includes('T')) return num * 1024; - if (size.includes('G')) return num; - if (size.includes('M')) return num / 1024; - if (size.includes('K')) return num / 1024 / 1024; - return num; -} - -async function resolveOpenclawVersion() { - if (cachedOpenclawVersion) return cachedOpenclawVersion; - - try { - const { stdout } = await execAsync('openclaw --version'); - const version = stdout.trim(); - cachedOpenclawVersion = version || 'unknown'; - return cachedOpenclawVersion; - } catch (err) { - log.debug('Failed to resolve OpenClaw version:', err.message); - cachedOpenclawVersion = 'unknown'; - return cachedOpenclawVersion; - } -} - -/** - * Collect OpenClaw status - */ -async function collectOpenclawStatus() { - try { - const [agents, openclawVersion] = await Promise.all([ - getOpenclawAgents(), - resolveOpenclawVersion(), - ]); - return { - openclawVersion, - pluginVersion: CONFIG.pluginVersion, - agent_count: agents.length, - agents: agents.map(a => ({ - id: a.id, - name: a.name, - status: a.status, - })), - }; - } catch (err) { - log.debug('Failed to collect OpenClaw status:', err.message); - return { - openclawVersion: await resolveOpenclawVersion(), - pluginVersion: CONFIG.pluginVersion, - agent_count: 0, - agents: [], - }; - } -} - -/** - * Get list of OpenClaw agents from local state - */ -function extractJsonPrefix(text) { - const trimmed = text.trim(); - if (!trimmed) return null; - - const startsWith = trimmed[0]; - if (startsWith !== '[' && startsWith !== '{') return null; - - let depth = 0; - let inString = false; - let escape = false; - for (let i = 0; i < trimmed.length; i += 1) { - const ch = trimmed[i]; - if (escape) { - escape = false; - continue; - } - if (ch === '\\') { - escape = true; - continue; - } - if (ch === '"') { - inString = !inString; - continue; - } - if (inString) continue; - if (ch === '[' || ch === '{') depth += 1; - if (ch === ']' || ch === '}') depth -= 1; - if (depth === 0) { - return trimmed.slice(0, i + 1); - } - } - return null; -} - -async function getOpenclawAgents() { - try { - try { - const { stdout } = await execAsync('openclaw agents list --json 2>/dev/null'); - const jsonPrefix = extractJsonPrefix(stdout); - if (jsonPrefix) { - const agents = JSON.parse(jsonPrefix); - if (Array.isArray(agents)) { - return agents.map((agent) => ({ - id: agent.id, - name: agent.name || agent.id, - status: agent.isDefault ? 'default' : 'configured', - })); - } - } - } catch (err) { - log.debug('Failed to get agents from `openclaw agents list --json`:', err.message); - } - - const agentConfigPath = `${CONFIG.openclawPath}/agents.json`; - try { - await access(agentConfigPath, constants.R_OK); - const data = JSON.parse(await readFile(agentConfigPath, 'utf8')); - if (Array.isArray(data.agents) && data.agents.length > 0) { - return data.agents; - } - } catch { - // fall through to directory-based discovery - } - - const agentsDir = `${CONFIG.openclawPath}/agents`; - await access(agentsDir, constants.R_OK); - const entries = await readdir(agentsDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .filter((entry) => entry.name !== 'main') - .map((entry) => ({ - id: entry.name, - name: entry.name, - status: 'configured', - })); - } catch { - return []; - } -} - -/** - * Build telemetry payload - */ -async function buildPayload() { - const system = await collectSystemMetrics(); - const openclaw = await collectOpenclawStatus(); - - return { - identifier: CONFIG.identifier, - timestamp: new Date().toISOString(), - ...system, - openclaw_version: openclaw.openclawVersion, - plugin_version: openclaw.pluginVersion, - agents: openclaw.agents, - }; -} - -/** - * Send telemetry via HTTP - */ -async function sendHttpHeartbeat() { - try { - const payload = await buildPayload(); - log.debug('Sending HTTP heartbeat...'); - - const headers = { - 'Content-Type': 'application/json', - 'X-Server-Identifier': CONFIG.identifier, - }; - - if (CONFIG.apiKey) { - headers['X-API-Key'] = CONFIG.apiKey; - } - - const response = await fetch(`${CONFIG.backendUrl}/monitor/server/heartbeat-v2`, { - method: 'POST', - headers, - body: JSON.stringify(payload), - }); - - if (response.ok) { - log.debug('HTTP heartbeat sent successfully'); - lastSuccessfulSend = Date.now(); - consecutiveFailures = 0; - return true; - } else { - throw new Error(`HTTP ${response.status}: ${await response.text()}`); - } - } catch (err) { - log.error('HTTP heartbeat failed:', err.message); - consecutiveFailures++; - return false; - } -} - -/** - * Main reporting loop - */ -async function reportingLoop() { - while (!isShuttingDown) { - try { - const success = await sendHttpHeartbeat(); - - let interval = CONFIG.reportIntervalSec * 1000; - if (!success) { - const backoff = Math.min(consecutiveFailures * 10000, 300000); - interval = Math.max(interval, backoff); - log.info(`Retry in ${interval}ms (backoff)`); - } - - await new Promise(resolve => setTimeout(resolve, interval)); - } catch (err) { - log.error('Reporting loop error:', err.message); - await new Promise(resolve => setTimeout(resolve, 30000)); - } - } -} - -/** - * Graceful shutdown - */ -function shutdown() { - log.info('Shutting down telemetry server...'); - isShuttingDown = true; - - if (wsConnection) { - wsConnection.close(); - } - - sendHttpHeartbeat().finally(() => { - process.exit(0); - }); -} - -// Handle signals -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); - -// Start -log.info('HarborForge Monitor Telemetry Server starting...'); -log.info('Config:', { - identifier: CONFIG.identifier, - backendUrl: CONFIG.backendUrl, - reportIntervalSec: CONFIG.reportIntervalSec, - hasApiKey: !!CONFIG.apiKey, - pluginVersion: CONFIG.pluginVersion, -}); - -if (!CONFIG.apiKey) { - log.warn('Missing HF_MONITOR_API_KEY environment variable - API authentication will fail'); -} - -reportingLoop();