Use api.runtime.version for openclaw version and api.runtime.config.loadConfig() for agent list. Eliminates the periodic openclaw agents list subprocess that caused high CPU usage.
556 lines
17 KiB
TypeScript
556 lines
17 KiB
TypeScript
/**
|
|
* 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 type { OpenClawAgentInfo } from './core/openclaw-agents';
|
|
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;
|
|
runtime?: {
|
|
version?: string;
|
|
config?: {
|
|
loadConfig?: () => any;
|
|
};
|
|
};
|
|
config?: Record<string, unknown>;
|
|
pluginConfig?: Record<string, unknown>;
|
|
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.
|
|
*/
|
|
function getBridgeClient(): MonitorBridgeClient | null {
|
|
const live = resolveConfig();
|
|
const port = live.monitor_port;
|
|
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.runtime?.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<typeof setInterval> | 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;
|
|
|
|
let agentNames: string[] = [];
|
|
try {
|
|
const cfg = api.runtime?.config?.loadConfig?.();
|
|
const agentsList = cfg?.agents?.list;
|
|
if (Array.isArray(agentsList)) {
|
|
agentNames = agentsList
|
|
.map((a: any) => typeof a === 'string' ? a : a?.name)
|
|
.filter(Boolean);
|
|
}
|
|
} catch { /* non-fatal */ }
|
|
|
|
const meta: OpenClawMeta = {
|
|
version: api.runtime?.version || api.version || 'unknown',
|
|
plugin_version: '0.3.1',
|
|
agents: agentNames.map(name => ({ name })),
|
|
};
|
|
|
|
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<boolean> {
|
|
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.monitor_port ?? 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)');
|
|
},
|
|
};
|