/** * HarborForge Plugin for OpenClaw * * Provides monitor-related tools and exposes OpenClaw metadata * for the HarborForge Monitor bridge (via monitor_port). * * Also integrates with HarborForge Calendar system to wake agents * for scheduled tasks (PLG-CAL-002, PLG-CAL-004). * * 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 { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; import { getPluginConfig } from './core/config'; import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge'; import { registerGatewayStartHook } from './hooks/gateway-start'; import { registerGatewayStopHook } from './hooks/gateway-stop'; import { createCalendarBridgeClient, createCalendarScheduler, CalendarScheduler, AgentWakeContext, } from './calendar'; interface PluginAPI { logger: { info: (...args: any[]) => void; error: (...args: any[]) => void; debug: (...args: any[]) => void; warn: (...args: any[]) => void; }; version?: string; config?: Record; pluginConfig?: Record; on: (event: string, handler: () => void) => void; registerTool: (factory: (ctx: any) => any) => void; /** Spawn a sub-agent with task context (OpenClaw 2.1+) */ spawn?: (options: { agentId?: string; task: string; model?: string; timeoutSeconds?: number; }) => Promise<{ sessionId: string; status: string }>; /** Get current agent status */ getAgentStatus?: () => Promise<{ status: string } | null>; } export default { id: 'harbor-forge', name: 'HarborForge', register(api: PluginAPI) { const logger = api.logger || { 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), }; function resolveConfig() { return getPluginConfig(api); } /** * Get the monitor bridge client if monitor_port is configured. * Legacy alias monitorPort is still accepted. */ function getBridgeClient(): MonitorBridgeClient | null { const live = resolveConfig() as any; const port = live.monitor_port ?? live.monitorPort; if (!port || port <= 0) return null; return new MonitorBridgeClient(port); } /** * Collect current system telemetry snapshot. * This data is exposed to the Monitor bridge when it queries the plugin. */ function collectTelemetry() { const live = resolveConfig(); 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.3.1', // Bumped for PLG-CAL-004 }, timestamp: new Date().toISOString(), }; } // Periodic metadata push interval handle let metaPushInterval: ReturnType | null = null; // Calendar scheduler instance let calendarScheduler: CalendarScheduler | null = null; /** * Push OpenClaw metadata to the Monitor bridge. * This enriches Monitor heartbeats with OpenClaw version/plugin/agent info. * Failures are non-fatal — Monitor continues to work without this data. */ async function pushMetaToMonitor() { const bridgeClient = getBridgeClient(); if (!bridgeClient) return; const meta: OpenClawMeta = { version: api.version || 'unknown', plugin_version: '0.3.1', agents: [], // TODO: populate from api agent list when available }; const ok = await bridgeClient.pushOpenClawMeta(meta); if (ok) { logger.debug('pushed OpenClaw metadata to Monitor bridge'); } else { logger.debug('Monitor bridge unreachable for metadata push (non-fatal)'); } } /** * Get current agent status from OpenClaw. * Falls back to querying backend if OpenClaw API unavailable. */ async function getAgentStatus(): Promise<'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline' | null> { // Try OpenClaw API first (if available) if (api.getAgentStatus) { try { const status = await api.getAgentStatus(); if (status?.status) { return status.status as 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline'; } } catch (err) { logger.debug('Failed to get agent status from OpenClaw API:', err); } } // Fallback: query backend for agent status const live = resolveConfig(); const agentId = process.env.AGENT_ID || 'unknown'; try { const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, { headers: { 'X-Agent-ID': agentId, 'X-Claw-Identifier': live.identifier || hostname(), }, }); if (response.ok) { const data = await response.json(); return data.status; } } catch (err) { logger.debug('Failed to get agent status from backend:', err); } return null; } /** * Wake/spawn agent with task context for slot execution. * This is the callback invoked by CalendarScheduler when a slot is ready. */ async function wakeAgent(context: AgentWakeContext): Promise { logger.info(`Waking agent for slot: ${context.taskDescription}`); try { // Method 1: Use OpenClaw spawn API if available (preferred) if (api.spawn) { const result = await api.spawn({ task: context.prompt, timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds }); if (result?.sessionId) { logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); // Track session completion trackSessionCompletion(result.sessionId, context); return true; } } // Method 2: Send notification/alert to wake agent (fallback) // This relies on the agent's heartbeat to check for notifications logger.warn('OpenClaw spawn API not available, using notification fallback'); // Send calendar wakeup notification via backend const live = resolveConfig(); const agentId = process.env.AGENT_ID || 'unknown'; const notifyResponse = await fetch(`${live.backendUrl}/calendar/agent/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Agent-ID': agentId, 'X-Claw-Identifier': live.identifier || hostname(), }, body: JSON.stringify({ agent_id: agentId, message: context.prompt, slot_id: context.slot.id || context.slot.virtual_id, task_description: context.taskDescription, }), }); return notifyResponse.ok; } catch (err) { logger.error('Failed to wake agent:', err); return false; } } /** * Track session completion and update slot status accordingly. */ function trackSessionCompletion(sessionId: string, context: AgentWakeContext): void { // Poll for session completion (simplified approach) // In production, this would use webhooks or event streaming const pollInterval = 30000; // 30 seconds const maxDuration = context.slot.estimated_duration * 60 * 1000; // Convert to ms const startTime = Date.now(); const poll = async () => { if (!calendarScheduler) return; const elapsed = Date.now() - startTime; // Check if session is complete (would use actual API in production) // For now, estimate completion based on duration if (elapsed >= maxDuration) { // Assume completion const actualMinutes = Math.round(elapsed / 60000); await calendarScheduler.completeCurrentSlot(actualMinutes); return; } // Continue polling setTimeout(poll, pollInterval); }; // Start polling setTimeout(poll, pollInterval); } /** * Initialize and start the calendar scheduler. */ function startCalendarScheduler(): void { const live = resolveConfig(); const agentId = process.env.AGENT_ID || 'unknown'; // Create calendar bridge client const calendarBridge = createCalendarBridgeClient( api, live.backendUrl || 'https://monitor.hangman-lab.top', agentId ); // Create and start scheduler calendarScheduler = createCalendarScheduler({ bridge: calendarBridge, getAgentStatus, wakeAgent, logger, heartbeatIntervalMs: 60000, // 1 minute debug: live.logLevel === 'debug', }); calendarScheduler.start(); logger.info('Calendar scheduler started'); } /** * Stop the calendar scheduler. */ function stopCalendarScheduler(): void { if (calendarScheduler) { calendarScheduler.stop(); calendarScheduler = null; logger.info('Calendar scheduler stopped'); } } registerGatewayStartHook(api, { logger, pushMetaToMonitor, startCalendarScheduler, setMetaPushInterval(handle) { metaPushInterval = handle; }, }); registerGatewayStopHook(api, { logger, getMetaPushInterval() { return metaPushInterval; }, clearMetaPushInterval() { metaPushInterval = null; }, stopCalendarScheduler, }); // Tool: plugin status api.registerTool(() => ({ name: 'harborforge_status', description: 'Get HarborForge plugin status and current telemetry snapshot', parameters: { type: 'object', properties: {}, }, async execute() { const live = resolveConfig(); const bridgeClient = getBridgeClient(); let monitorBridge = null; if (bridgeClient) { const health = await bridgeClient.health(); monitorBridge = health ? { connected: true, ...health } : { connected: false, error: 'Monitor bridge unreachable' }; } // Get calendar scheduler status const calendarStatus = calendarScheduler ? { running: calendarScheduler.isRunning(), processing: calendarScheduler.isProcessing(), currentSlot: calendarScheduler.getCurrentSlot(), isRestartPending: calendarScheduler.isRestartPending(), } : null; return { enabled: live.enabled !== false, config: { backendUrl: live.backendUrl, identifier: live.identifier || hostname(), monitorPort: (live as any).monitor_port ?? (live as any).monitorPort ?? null, reportIntervalSec: live.reportIntervalSec, hasApiKey: Boolean(live.apiKey), }, monitorBridge, calendar: calendarStatus, telemetry: collectTelemetry(), }; }, })); // 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(); }, })); // Tool: query Monitor bridge for host hardware telemetry api.registerTool(() => ({ name: 'harborforge_monitor_telemetry', description: 'Query HarborForge Monitor bridge for host hardware telemetry (CPU, memory, disk, etc.)', parameters: { type: 'object', properties: {}, }, async execute() { const bridgeClient = getBridgeClient(); if (!bridgeClient) { return { error: 'Monitor bridge not configured (monitor_port not set or 0)', }; } const data = await bridgeClient.telemetry(); if (!data) { return { error: 'Monitor bridge unreachable', }; } return data; }, })); // Tool: calendar slot management api.registerTool(() => ({ name: 'harborforge_calendar_status', description: 'Get current calendar scheduler status and pending slots', parameters: { type: 'object', properties: {}, }, async execute() { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } return { running: calendarScheduler.isRunning(), processing: calendarScheduler.isProcessing(), currentSlot: calendarScheduler.getCurrentSlot(), state: calendarScheduler.getState(), isRestartPending: calendarScheduler.isRestartPending(), stateFilePath: calendarScheduler.getStateFilePath(), }; }, })); // Tool: complete current slot (for agent to report completion) api.registerTool(() => ({ name: 'harborforge_calendar_complete', description: 'Complete the current calendar slot with actual duration', parameters: { type: 'object', properties: { actualDurationMinutes: { type: 'number', description: 'Actual time spent on the task in minutes', }, }, required: ['actualDurationMinutes'], }, async execute(params: { actualDurationMinutes: number }) { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } await calendarScheduler.completeCurrentSlot(params.actualDurationMinutes); return { success: true, message: 'Slot completed' }; }, })); // Tool: abort current slot (for agent to report failure) api.registerTool(() => ({ name: 'harborforge_calendar_abort', description: 'Abort the current calendar slot', parameters: { type: 'object', properties: { reason: { type: 'string', description: 'Reason for aborting', }, }, }, async execute(params: { reason?: string }) { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } await calendarScheduler.abortCurrentSlot(params.reason); return { success: true, message: 'Slot aborted' }; }, })); // Tool: pause current slot api.registerTool(() => ({ name: 'harborforge_calendar_pause', description: 'Pause the current calendar slot', parameters: { type: 'object', properties: {}, }, async execute() { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } await calendarScheduler.pauseCurrentSlot(); return { success: true, message: 'Slot paused' }; }, })); // Tool: resume current slot api.registerTool(() => ({ name: 'harborforge_calendar_resume', description: 'Resume the paused calendar slot', parameters: { type: 'object', properties: {}, }, async execute() { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } await calendarScheduler.resumeCurrentSlot(); return { success: true, message: 'Slot resumed' }; }, })); // Tool: check ScheduledGatewayRestart status api.registerTool(() => ({ name: 'harborforge_restart_status', description: 'Check if a gateway restart is pending (PLG-CAL-004)', parameters: { type: 'object', properties: {}, }, async execute() { if (!calendarScheduler) { return { error: 'Calendar scheduler not running' }; } const isPending = calendarScheduler.isRestartPending(); const stateFilePath = calendarScheduler.getStateFilePath(); return { isRestartPending: isPending, stateFilePath: stateFilePath, message: isPending ? 'A gateway restart has been scheduled. The scheduler has been paused.' : 'No gateway restart is pending.', }; }, })); logger.info('HarborForge plugin registered (id: harbor-forge)'); }, };