1. ScheduleCache: local cache of today's schedule, synced every 5 min
from HF backend via new getDaySchedule() API
2. Wakeup prompts updated to reference daily-routine skill workflows
(task-handson, plan-schedule, slot-complete)
3. Agent wakeup via dispatchInboundMessageWithDispatcher (in-process)
- Same mechanism as Discord plugin
- Creates unique session per slot: agent:{agentId}:hf-calendar:slot-{slotId}
- No WebSocket, CLI, or cron dependency
- Verified working on test environment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
600 lines
19 KiB
TypeScript
600 lines
19 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);
|
|
}
|
|
|
|
/** Resolve agent ID from env, config, or fallback. */
|
|
function resolveAgentId(): string {
|
|
if (process.env.AGENT_ID) return process.env.AGENT_ID;
|
|
const cfg = api.runtime?.config?.loadConfig?.();
|
|
return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown';
|
|
}
|
|
|
|
/**
|
|
* 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 = resolveAgentId();
|
|
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 agent via dispatchInboundMessage — same mechanism used by Discord plugin.
|
|
* Direct in-process call, no WebSocket or CLI needed.
|
|
*/
|
|
async function wakeAgent(agentId: string): Promise<boolean> {
|
|
logger.info(`Waking agent ${agentId}: has due slots`);
|
|
|
|
const sessionKey = `agent:${agentId}:hf-wakeup`;
|
|
|
|
try {
|
|
const sdkPath = 'openclaw/plugin-sdk/reply-runtime';
|
|
const { dispatchInboundMessageWithDispatcher } = await import(
|
|
/* webpackIgnore: true */ sdkPath
|
|
);
|
|
|
|
const cfg = api.runtime?.config?.loadConfig?.();
|
|
if (!cfg) {
|
|
logger.error('Cannot load OpenClaw config for dispatch');
|
|
return false;
|
|
}
|
|
|
|
const wakeupMessage = `You have due slots. Follow the \`hf-wakeup\` workflow of skill \`hf-hangman-lab\` to proceed. Only reply \`WAKEUP_OK\` in this session.`;
|
|
|
|
const result = await dispatchInboundMessageWithDispatcher({
|
|
ctx: {
|
|
Body: wakeupMessage,
|
|
SessionKey: sessionKey,
|
|
From: 'harborforge-calendar',
|
|
Provider: 'harborforge',
|
|
},
|
|
cfg,
|
|
dispatcherOptions: {
|
|
deliver: async (payload: any) => {
|
|
const text = (payload.text || '').trim();
|
|
logger.info(`Agent ${agentId} wakeup reply: ${text.slice(0, 100)}`);
|
|
},
|
|
},
|
|
});
|
|
|
|
logger.info(`Agent ${agentId} dispatched: ${result?.status || 'ok'}`);
|
|
return true;
|
|
|
|
} catch (err: any) {
|
|
const msg = err?.message || err?.code || String(err);
|
|
const stack = err?.stack?.split('\n').slice(0, 3).join(' | ') || '';
|
|
logger.error(`Failed to dispatch agent for slot: ${msg} ${stack}`);
|
|
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();
|
|
|
|
// Create bridge client (claw-instance level, not per-agent)
|
|
const calendarBridge = createCalendarBridgeClient(
|
|
api,
|
|
live.backendUrl || 'https://monitor.hangman-lab.top',
|
|
'unused' // agentId no longer needed at bridge level
|
|
);
|
|
|
|
// Multi-agent sync + check loop
|
|
const { MultiAgentScheduleCache } = require('./calendar/schedule-cache') as typeof import('./calendar/schedule-cache');
|
|
const scheduleCache = new MultiAgentScheduleCache();
|
|
|
|
const SYNC_INTERVAL_MS = 300_000; // 5 min
|
|
const CHECK_INTERVAL_MS = 30_000; // 30 sec
|
|
|
|
// Sync: pull all agent schedules from backend
|
|
async function runSync() {
|
|
try {
|
|
const result = await calendarBridge.syncSchedules();
|
|
if (result) {
|
|
scheduleCache.sync(result.date, result.schedules);
|
|
const status = scheduleCache.getStatus();
|
|
logger.info(`Schedule synced: ${status.agentCount} agents, ${status.totalSlots} slots`);
|
|
}
|
|
} catch (err) {
|
|
logger.warn(`Schedule sync failed: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
// Check: find agents with due slots and wake them
|
|
async function runCheck() {
|
|
const now = new Date();
|
|
const agentsWithDue = scheduleCache.getAgentsWithDueSlots(now);
|
|
|
|
for (const { agentId } of agentsWithDue) {
|
|
// Check if agent is busy
|
|
const status = await calendarBridge.getAgentStatus(agentId);
|
|
if (status === 'busy' || status === 'offline' || status === 'exhausted') {
|
|
continue;
|
|
}
|
|
|
|
// Wake the agent
|
|
await wakeAgent(agentId);
|
|
}
|
|
}
|
|
|
|
// Initial sync
|
|
runSync();
|
|
|
|
// Start intervals
|
|
const syncHandle = setInterval(runSync, SYNC_INTERVAL_MS);
|
|
const checkHandle = setInterval(runCheck, CHECK_INTERVAL_MS);
|
|
|
|
// Store handles for cleanup (reuse calendarScheduler variable)
|
|
(calendarScheduler as any) = {
|
|
stop() {
|
|
clearInterval(syncHandle);
|
|
clearInterval(checkHandle);
|
|
logger.info('Calendar scheduler stopped');
|
|
},
|
|
};
|
|
|
|
logger.info('Calendar scheduler started (multi-agent sync mode)');
|
|
}
|
|
|
|
/**
|
|
* 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)');
|
|
},
|
|
};
|