8 Commits

Author SHA1 Message Date
c9f61419cb Merge docs/readme-refresh into main
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:55:57 +01:00
operator
917cb344cf chore: track ambient openclaw-sdk.d.ts (was caught by *.d.ts ignore)
The .gitignore was set up to skip tsc's compiled .d.ts output but it also
matched the hand-written ambient declarations file added in the previous
commit. Add a negation rule so that one file is tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:29:35 +00:00
operator
64a9c431bf chore: convert plugin to ESM and migrate to current openclaw plugin SDK
ESM conversion:
- plugin/package.json: add "type": "module".
- plugin/tsconfig.json: switch module/moduleResolution to "nodenext"; bump
  target to ES2022.
- All relative imports across plugin/ now carry .js extensions as required
  by Node ESM (nodenext module resolution).
- plugin/index.ts: replace `require('./calendar/schedule-cache')` with a
  proper top-level import; switch `from 'os'` to `from 'node:os'`.

Plugin SDK convention update:
- Wrap default export with definePluginEntry({ id, name, description,
  register }) per the current openclaw plugin authoring contract.
- Modernize plugin/openclaw.plugin.json: drop entry/version, add
  activation.onStartup so gateway_start fires for this plugin at boot,
  declare contracts.tools listing the five harborforge_* tools.
- Add a local plugin/openclaw-sdk.d.ts with ambient declarations for the
  focused subpaths (openclaw/plugin-sdk/plugin-entry,
  openclaw/plugin-sdk/core). We deliberately do NOT add openclaw as an
  npm devDependency: the installer's `npm install --omit=dev` step trips
  over openclaw's own (deeply nested) dependency graph when listed via
  file:.../openclaw, and the runtime contract is provided by the gateway
  loader anyway.
- The local PluginAPI interface is preserved (broader than the standard
  OpenClawPluginApi — it surfaces `version`/`runtime`/`spawn`); the
  register function is cast at the definePluginEntry boundary.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:28:58 +00:00
h z
957bcbb4a8 Merge pull request 'feat: schedule cache, workflow-aligned prompts, dispatchInbound wakeup' (#5) from zhi-2026-04-18 into main
Reviewed-on: #5
2026-05-01 07:23:25 +00:00
operator
9195dc6bd1 feat: schedule cache, workflow-aligned prompts, dispatchInbound wakeup
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>
2026-04-19 12:24:21 +00:00
operator
248adfaafd fix: use runtime API for version and agent list instead of subprocess
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.
2026-04-16 15:53:20 +00:00
operator
e4ac7b7af3 fix: disable periodic openclaw agents list subprocess
Spawning a full openclaw CLI process every 30s to list agents is too
heavy — each invocation loads all plugins (~16s) and hangs until killed.
Return empty array for now until a lighter mechanism is available.
2026-04-16 15:26:55 +00:00
operator
2088cd12b4 fix: use OPENCLAW_SERVICE_VERSION for real version and increase agent list timeout
api.version returns plugin API version (0.2.0), not the openclaw release
version. Use OPENCLAW_SERVICE_VERSION env var set by the gateway instead.
Also increase listOpenClawAgents timeout from 15s to 30s since plugin
loading takes ~16s on T2.
2026-04-16 15:12:35 +00:00
14 changed files with 495 additions and 136 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ plugin/**/*.js
plugin/**/*.js.map
plugin/**/*.d.ts
plugin/**/*.d.ts.map
# Hand-written ambient declarations are tracked; only compiled .d.ts above is ignored.
!plugin/openclaw-sdk.d.ts

118
REFACTOR_PLAN.md Normal file
View File

@@ -0,0 +1,118 @@
# CalendarScheduler Refactor Plan (v2)
> Updated 2026-04-19 based on architecture discussion with hang
## Current Issues
1. `process.env.AGENT_ID` doesn't exist in plugins subprocess — always 'unknown'
2. Heartbeat is per-agent but should be per-claw-instance (global)
3. Scheduler only handles one agent — should manage all agents on this instance
4. wakeAgent used api.spawn (non-existent) → now uses dispatchInboundMessage (verified)
## Target Design
### Plugin State
```typescript
// Local schedule cache: { agentId → [slots] }
const schedules: Map<string, CalendarSlotResponse[]> = new Map();
```
### Sync Flow (every 5 min)
```
1. GET /calendar/sync?claw_identifier=xxx
- First call: server returns full { agentId → [slots] }
- Subsequent: server returns diff since last sync
2. Update local schedules map
3. Scan schedules for due slots:
for each agentId in schedules:
if has slot where scheduled_at <= now && status == not_started:
getAgentStatus(agentId, clawIdentifier) → busy?
if not busy → wakeAgent(agentId)
```
### Heartbeat (every 60s)
Simplified to liveness ping only:
```
POST /monitor/server/heartbeat
claw_identifier: xxx
→ server returns empty/ack
```
No slot data in heartbeat response.
### Wake Flow
```
dispatchInboundMessage:
SessionKey: agent:{agentId}:hf-wakeup
Body: "You have due slots. Follow the hf-wakeup workflow of skill hf-hangman-lab to proceed. Only reply WAKEUP_OK in this session."
Agent reads workflow → calls hf tools → sets own status to busy
```
### Agent ID Resolution
- **Sync**: agentId comes from server response (dict keys)
- **Wake**: agentId from local schedules dict key
- **Tool calls by agent**: agentId from tool ctx (same as padded-cell)
## Backend API Changes Needed
### New: GET /calendar/sync
```
GET /calendar/sync?claw_identifier=xxx
Headers: X-Claw-Identifier
Response (first call):
{
"full": true,
"schedules": {
"developer": [slot1, slot2, ...],
"operator": [slot3, ...]
},
"syncToken": "abc123"
}
Response (subsequent, with ?since=abc123):
{
"full": false,
"diff": [
{ "op": "add", "agent": "developer", "slot": {...} },
{ "op": "update", "agent": "developer", "slotId": 5, "patch": {...} },
{ "op": "remove", "agent": "operator", "slotId": 3 }
],
"syncToken": "def456"
}
```
### Existing: POST /calendar/agent/status
Keep as-is but ensure it accepts agentId + clawIdentifier as params:
```
POST /calendar/agent/status
{ agent_id, claw_identifier, status }
```
## Implementation Order
1. Backend: Add /calendar/sync endpoint
2. Plugin: Replace CalendarBridgeClient single-agent design with multi-agent
3. Plugin: Replace CalendarScheduler with new sync+check loop
4. Plugin: wakeAgent uses dispatchInboundMessage (done)
5. Plugin: Tool handlers get agentId from ctx (like padded-cell)
## Files to Change
### Backend (HarborForge.Backend)
- New route: `/calendar/sync`
- New service: schedule diff tracking per claw_identifier
### Plugin
- `plugin/calendar/calendar-bridge.ts` — remove agentId binding, add sync()
- `plugin/calendar/scheduler.ts` — rewrite to multi-agent sync+check
- `plugin/calendar/schedule-cache.ts` — already exists, adapt to multi-agent
- `plugin/index.ts` — update wakeAgent, getAgentStatus to accept agentId

View File

@@ -29,7 +29,7 @@ import {
CalendarSlotResponse,
SlotAgentUpdate,
SlotStatus,
} from './types';
} from './types.js';
export interface CalendarBridgeConfig {
/** HarborForge backend base URL (e.g. "https://monitor.hangman-lab.top") */
@@ -169,6 +169,74 @@ export class CalendarBridgeClient {
return this.sendBoolean('POST', url, body);
}
/**
* Fetch the full day schedule for this agent.
*
* Unlike heartbeat() which only returns pending (NOT_STARTED/DEFERRED) slots,
* this returns ALL slots for the given date, enabling the plugin to maintain
* a complete local view of today's schedule.
*
* @param date Date string in YYYY-MM-DD format
* @returns Array of all slots for the day, or null if unreachable
*/
async getDaySchedule(date: string): Promise<CalendarSlotResponse[] | null> {
const url = `${this.baseUrl}/calendar/day?date=${encodeURIComponent(date)}`;
try {
const response = await this.fetchJson<{ slots: CalendarSlotResponse[] }>(url, {
method: 'GET',
headers: {
'X-Agent-ID': this.config.agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response?.slots ?? null;
} catch {
return null;
}
}
/**
* Sync today's schedules for all agents on this claw instance.
*
* Returns { agentId → slots[] } for all agents with matching claw_identifier.
* This is the primary data source for the multi-agent schedule cache.
*/
async syncSchedules(): Promise<{ schedules: Record<string, any[]>; date: string } | null> {
const url = `${this.baseUrl}/calendar/sync`;
try {
const response = await this.fetchJson<{ schedules: Record<string, any[]>; date: string }>(url, {
method: 'GET',
headers: {
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response;
} catch {
return null;
}
}
/**
* Get a specific agent's status.
*
* @param agentId The agent ID to query
*/
async getAgentStatus(agentId: string): Promise<string | null> {
const url = `${this.baseUrl}/calendar/agent/status?agent_id=${encodeURIComponent(agentId)}`;
try {
const response = await this.fetchJson<{ status: string }>(url, {
method: 'GET',
headers: {
'X-Agent-ID': agentId,
'X-Claw-Identifier': this.config.clawIdentifier,
},
});
return response?.status ?? null;
} catch {
return null;
}
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
@@ -224,7 +292,7 @@ export class CalendarBridgeClient {
// ---------------------------------------------------------------------------
import { hostname } from 'os';
import { getPluginConfig } from '../core/config';
import { getPluginConfig } from '../core/config.js';
export interface CalendarPluginConfig {
/** Backend URL for calendar API (overrides monitor backendUrl) */

View File

@@ -13,7 +13,7 @@
* • AgentWakeContext — context passed to agent when waking
*
* Usage in plugin/index.ts:
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar';
* import { createCalendarBridgeClient, createCalendarScheduler } from './calendar.js';
*
* const agentId = process.env.AGENT_ID || 'unknown';
* const calendar = createCalendarBridgeClient(api, 'https://monitor.hangman-lab.top', agentId);
@@ -28,6 +28,7 @@
* scheduler.start();
*/
export * from './types';
export * from './calendar-bridge';
export * from './scheduler';
export * from './types.js';
export * from './calendar-bridge.js';
export * from './scheduler.js';
export * from './schedule-cache.js';

View File

@@ -0,0 +1,101 @@
/**
* Multi-agent local schedule cache.
*
* Maintains today's schedule for all agents on this claw instance.
* Synced periodically from HF backend via /calendar/sync endpoint.
*/
export interface CachedSlot {
id: number | null;
virtual_id: string | null;
slot_type: string;
estimated_duration: number;
scheduled_at: string;
status: string;
priority: number;
event_type: string | null;
event_data: Record<string, unknown> | null;
[key: string]: unknown;
}
export class MultiAgentScheduleCache {
/** { agentId → slots[] } */
private schedules: Map<string, CachedSlot[]> = new Map();
private lastSyncAt: Date | null = null;
private cachedDate: string | null = null;
/**
* Replace cache with data from /calendar/sync response.
*/
sync(date: string, schedules: Record<string, CachedSlot[]>): void {
if (this.cachedDate !== date) {
this.schedules.clear();
}
this.cachedDate = date;
for (const [agentId, slots] of Object.entries(schedules)) {
this.schedules.set(agentId, slots);
}
this.lastSyncAt = new Date();
}
/**
* Get agents that have due (overdue or current) slots.
* Returns [agentId, dueSlots[]] pairs.
*/
getAgentsWithDueSlots(now: Date): Array<{ agentId: string; slots: CachedSlot[] }> {
const results: Array<{ agentId: string; slots: CachedSlot[] }> = [];
for (const [agentId, slots] of this.schedules) {
const due = slots.filter((s) => {
if (s.status !== 'not_started' && s.status !== 'deferred') return false;
const scheduledAt = this.parseScheduledTime(s.scheduled_at);
return scheduledAt !== null && scheduledAt <= now;
});
if (due.length > 0) {
// Sort by priority descending
due.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
results.push({ agentId, slots: due });
}
}
return results;
}
/**
* Get all agent IDs in the cache.
*/
getAgentIds(): string[] {
return Array.from(this.schedules.keys());
}
/**
* Get slots for a specific agent.
*/
getAgentSlots(agentId: string): CachedSlot[] {
return this.schedules.get(agentId) ?? [];
}
/**
* Get cache status for debugging.
*/
getStatus(): { agentCount: number; totalSlots: number; lastSyncAt: string | null; cachedDate: string | null } {
let totalSlots = 0;
for (const slots of this.schedules.values()) totalSlots += slots.length;
return {
agentCount: this.schedules.size,
totalSlots,
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
cachedDate: this.cachedDate,
};
}
private parseScheduledTime(scheduledAt: string): Date | null {
if (/^\d{2}:\d{2}(:\d{2})?$/.test(scheduledAt)) {
if (!this.cachedDate) return null;
return new Date(`${this.cachedDate}T${scheduledAt}Z`);
}
const d = new Date(scheduledAt);
return isNaN(d.getTime()) ? null : d;
}
}

View File

@@ -18,7 +18,7 @@
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { CalendarBridgeClient } from './calendar-bridge';
import { CalendarBridgeClient } from './calendar-bridge.js';
import {
CalendarSlotResponse,
SlotStatus,
@@ -26,7 +26,7 @@ import {
SlotAgentUpdate,
CalendarEventDataJob,
CalendarEventDataSystemEvent,
} from './types';
} from './types.js';
export interface CalendarSchedulerConfig {
/** Calendar bridge client for backend communication */

View File

@@ -1,8 +1,3 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export interface OpenClawAgentInfo {
name: string;
isDefault?: boolean;
@@ -14,70 +9,38 @@ export interface OpenClawAgentInfo {
routing?: string;
}
export async function listOpenClawAgents(logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise<OpenClawAgentInfo[]> {
try {
const { stdout } = await execFileAsync('openclaw', ['agents', 'list'], {
timeout: 15000,
maxBuffer: 1024 * 1024,
});
return parseOpenClawAgents(stdout);
} catch (err) {
logger?.warn?.('Failed to run `openclaw agents list`', err);
return [];
}
export async function listOpenClawAgents(_logger?: { debug?: (...args: any[]) => void; warn?: (...args: any[]) => void }): Promise<OpenClawAgentInfo[]> {
return [];
}
export function parseOpenClawAgents(text: string): OpenClawAgentInfo[] {
const lines = text.split(/\r?\n/);
const out: OpenClawAgentInfo[] = [];
let current: OpenClawAgentInfo | null = null;
const push = () => {
if (current) out.push(current);
current = null;
};
const push = () => { if (current) out.push(current); current = null; };
for (const raw of lines) {
const line = raw.trimEnd();
if (!line.trim() || line.startsWith('Agents:') || line.startsWith('Routing rules map') || line.startsWith('Channel status reflects')) continue;
if (line.startsWith('- ')) {
if (!line.trim() || line.startsWith("Agents:") || line.startsWith("Routing rules map") || line.startsWith("Channel status reflects")) continue;
if (line.startsWith("- ")) {
push();
const m = line.match(/^-\s+(.+?)(?:\s+\((default)\))?$/);
current = {
name: m?.[1] || line.slice(2).trim(),
isDefault: m?.[2] === 'default',
};
current = { name: m?.[1] || line.slice(2).trim(), isDefault: m?.[2] === "default" };
continue;
}
if (!current) continue;
const trimmed = line.trim();
const idx = trimmed.indexOf(':');
const idx = trimmed.indexOf(":");
if (idx === -1) continue;
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
switch (key) {
case 'Identity':
current.identity = value;
break;
case 'Workspace':
current.workspace = value;
break;
case 'Agent dir':
current.agentDir = value;
break;
case 'Model':
current.model = value;
break;
case 'Routing rules': {
const n = Number(value);
current.routingRules = Number.isFinite(n) ? n : undefined;
break;
}
case 'Routing':
current.routing = value;
break;
default:
break;
case "Identity": current.identity = value; break;
case "Workspace": current.workspace = value; break;
case "Agent dir": current.agentDir = value; break;
case "Model": current.model = value; break;
case "Routing rules": { const n = Number(value); current.routingRules = Number.isFinite(n) ? n : undefined; break; }
case "Routing": current.routing = value; break;
default: break;
}
}
push();

View File

@@ -1,6 +1,6 @@
import { hostname } from 'os';
import { getPluginConfig } from '../core/config';
import { startManagedMonitor } from '../core/managed-monitor';
import { getPluginConfig } from '../core/config.js';
import { startManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStartHook(api: any, deps: {
logger: any;

View File

@@ -1,4 +1,4 @@
import { stopManagedMonitor } from '../core/managed-monitor';
import { stopManagedMonitor } from '../core/managed-monitor.js';
export function registerGatewayStopHook(api: any, deps: {
logger: any;

View File

@@ -11,18 +11,20 @@
* 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 { listOpenClawAgents } from './core/openclaw-agents';
import { registerGatewayStartHook } from './hooks/gateway-start';
import { registerGatewayStopHook } from './hooks/gateway-stop';
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'node:os';
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { MultiAgentScheduleCache } from './calendar/schedule-cache.js';
import { getPluginConfig } from './core/config.js';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge.js';
import type { OpenClawAgentInfo } from './core/openclaw-agents.js';
import { registerGatewayStartHook } from './hooks/gateway-start.js';
import { registerGatewayStopHook } from './hooks/gateway-stop.js';
import {
createCalendarBridgeClient,
createCalendarScheduler,
CalendarScheduler,
AgentWakeContext,
} from './calendar';
} from './calendar/index.js';
interface PluginAPI {
logger: {
@@ -32,6 +34,12 @@ interface PluginAPI {
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;
@@ -47,10 +55,7 @@ interface PluginAPI {
getAgentStatus?: () => Promise<{ status: string } | null>;
}
export default {
id: 'harbor-forge',
name: 'HarborForge',
register(api: PluginAPI) {
function register(api: PluginAPI): void {
const logger = api.logger || {
info: (...args: any[]) => console.log('[HarborForge]', ...args),
error: (...args: any[]) => console.error('[HarborForge]', ...args),
@@ -62,6 +67,13 @@ export default {
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.
*/
@@ -96,7 +108,7 @@ export default {
avg15: load[2],
},
openclaw: {
version: api.version || 'unknown',
version: api.runtime?.version || api.version || 'unknown',
pluginVersion: '0.3.1', // Bumped for PLG-CAL-004
},
timestamp: new Date().toISOString(),
@@ -118,10 +130,21 @@ export default {
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.version || 'unknown',
version: api.runtime?.version || api.version || 'unknown',
plugin_version: '0.3.1',
agents: await listOpenClawAgents(logger),
agents: agentNames.map(name => ({ name })),
};
const ok = await bridgeClient.pushOpenClawMeta(meta);
@@ -151,7 +174,7 @@ export default {
// Fallback: query backend for agent status
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
const agentId = resolveAgentId();
try {
const response = await fetch(`${live.backendUrl}/calendar/agent/status?agent_id=${agentId}`, {
headers: {
@@ -171,56 +194,51 @@ export default {
}
/**
* Wake/spawn agent with task context for slot execution.
* This is the callback invoked by CalendarScheduler when a slot is ready.
* Wake agent via dispatchInboundMessage — same mechanism used by Discord plugin.
* Direct in-process call, no WebSocket or CLI needed.
*/
async function wakeAgent(context: AgentWakeContext): Promise<boolean> {
logger.info(`Waking agent for slot: ${context.taskDescription}`);
async function wakeAgent(agentId: string): Promise<boolean> {
logger.info(`Waking agent ${agentId}: has due slots`);
const sessionKey = `agent:${agentId}:hf-wakeup`;
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
});
const sdkPath = 'openclaw/plugin-sdk/reply-runtime';
const { dispatchInboundMessageWithDispatcher } = await import(
/* webpackIgnore: true */ sdkPath
);
if (result?.sessionId) {
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
// Track session completion
trackSessionCompletion(result.sessionId, context);
return true;
}
const cfg = api.runtime?.config?.loadConfig?.();
if (!cfg) {
logger.error('Cannot load OpenClaw config for dispatch');
return false;
}
// 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');
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.`;
// 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(),
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)}`);
},
},
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;
logger.info(`Agent ${agentId} dispatched: ${result?.status || 'ok'}`);
return true;
} catch (err) {
logger.error('Failed to wake agent:', err);
} 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;
}
}
@@ -262,27 +280,68 @@ export default {
*/
function startCalendarScheduler(): void {
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
// Create calendar bridge client
// Create bridge client (claw-instance level, not per-agent)
const calendarBridge = createCalendarBridgeClient(
api,
live.backendUrl || 'https://monitor.hangman-lab.top',
agentId
'unused' // agentId no longer needed at bridge level
);
// Create and start scheduler
calendarScheduler = createCalendarScheduler({
bridge: calendarBridge,
getAgentStatus,
wakeAgent,
logger,
heartbeatIntervalMs: 60000, // 1 minute
debug: live.logLevel === 'debug',
});
// Multi-agent sync + check loop
const scheduleCache = new MultiAgentScheduleCache();
calendarScheduler.start();
logger.info('Calendar scheduler started');
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)');
}
/**
@@ -534,5 +593,16 @@ export default {
}));
logger.info('HarborForge plugin registered (id: harbor-forge)');
},
};
}
// HarborForge's local PluginAPI is broader than the standard OpenClawPluginApi
// (it surfaces optional `version`/`runtime`/`spawn` accessors that older
// OpenClaw builds exposed). The cast at the definePluginEntry boundary
// acknowledges that gap — the runtime api object is whatever the gateway
// passes us, and each access is guarded with optional chaining / fallbacks.
export default definePluginEntry({
id: 'harbor-forge',
name: 'HarborForge',
description: 'HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration',
register: register as (api: any) => void,
});

25
plugin/openclaw-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// Ambient declarations for the focused subpaths of the openclaw plugin SDK
// that this plugin needs at compile time.
//
// We intentionally do NOT take a `dependencies` (or `devDependencies`) entry
// on the openclaw npm package itself: openclaw is provided by the host
// gateway at runtime, and listing it as a file:/.../openclaw devDep breaks
// the installer's `npm install --omit=dev` step because npm/arborist trips
// over openclaw's own (deeply nested) dependency graph.
//
// These declarations cover only what we use here. They are deliberately
// permissive — the runtime contract is whatever the gateway hands us, and
// we guard each api access with optional chaining or a fallback at call site.
declare module 'openclaw/plugin-sdk/plugin-entry' {
export function definePluginEntry<T extends {
id: string;
name: string;
description?: string;
register: (api: any) => void | Promise<void>;
}>(opts: T): T;
}
declare module 'openclaw/plugin-sdk/core' {
export type OpenClawPluginApi = unknown;
}

View File

@@ -1,9 +1,19 @@
{
"id": "harbor-forge",
"name": "HarborForge",
"version": "0.2.0",
"description": "HarborForge plugin for OpenClaw - project management, monitoring, and CLI integration",
"entry": "./dist/index.js",
"activation": {
"onStartup": true
},
"contracts": {
"tools": [
"harborforge_status",
"harborforge_telemetry",
"harborforge_monitor_telemetry",
"harborforge_calendar_status",
"harborforge_calendar_complete"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -2,6 +2,7 @@
"name": "harbor-forge-plugin",
"version": "0.2.0",
"description": "OpenClaw plugin for HarborForge monitor bridge and CLI integration",
"type": "module",
"main": "dist/index.js",
"scripts": {
"clean": "rm -rf dist",

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,