Compare commits

..

3 Commits

Author SHA1 Message Date
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
9 changed files with 79 additions and 568 deletions

View File

@@ -1,112 +0,0 @@
# CalendarScheduler Refactor Plan
## Current Design
```
Every 60s:
heartbeat() → POST /calendar/agent/heartbeat → returns pending slots
if idle → select highest priority → executeSlot → wakeAgent(spawn)
if busy → defer all pending slots
```
**Problems:**
1. Every heartbeat queries backend for pending slots — no local awareness of full schedule
2. Cannot detect slots assigned by other agents between heartbeats
3. 60s interval is too frequent for sync but too infrequent for precise wakeup
4. Wakeup via `api.spawn()` creates a plain session, not a Discord private channel
## Target Design
```
Every 5-10 min (sync interval):
syncSchedule() → GET /calendar/day → update local today cache
Every 30s (check interval):
checkDueSlots() → scan local cache for due slots
if due slot found:
confirmAgentStatus() → GET /calendar/agent/status
if not busy → wakeAgent (via Dirigent moderator bot private channel)
```
## Changes Required
### 1. Add Local Schedule Cache
New class `ScheduleCache`:
```typescript
class ScheduleCache {
private slots: Map<string, CalendarSlotResponse>; // slotId → slot
private lastSyncAt: Date | null;
async sync(bridge: CalendarBridgeClient): Promise<void>; // fetch today's full schedule
getDueSlots(now: Date): CalendarSlotResponse[]; // scheduled_at <= now && NOT_STARTED/DEFERRED
updateSlot(id: string, update: Partial<CalendarSlotResponse>): void; // local update
getAll(): CalendarSlotResponse[];
}
```
### 2. Add CalendarBridgeClient.getDaySchedule()
New endpoint call:
```typescript
async getDaySchedule(date: string): Promise<CalendarSlotResponse[]>
// GET /calendar/day?date=YYYY-MM-DD
```
This fetches ALL slots for the day, not just pending ones. The existing `heartbeat()` only returns NOT_STARTED/DEFERRED.
### 3. Split Heartbeat into Sync + Check
**Replace** single `runHeartbeat()` with two intervals:
```typescript
// Sync: every 5 min — pull full schedule from backend
this.syncInterval = setInterval(() => this.runSync(), 300_000);
// Check: every 30s — scan local cache for due slots
this.checkInterval = setInterval(() => this.runCheck(), 30_000);
```
`runSync()`:
1. `bridge.getDaySchedule(today)` → update cache
2. Still send heartbeat to keep backend informed of agent liveness
`runCheck()`:
1. `cache.getDueSlots(now)` → find due slots
2. Filter out session-deferred slots
3. If agent idle → select highest priority → execute
### 4. Wakeup via Dirigent (future)
Change `wakeAgent()` to create a private Discord channel via Dirigent moderator bot instead of `api.spawn()`. This requires:
- Access to Dirigent's moderator bot token or cross-plugin API
- Creating a private channel with only the target agent
- Posting the wakeup prompt as a message
**For now:** Keep `api.spawn()` as the wakeup method. The Dirigent integration can be added later as it requires cross-plugin coordination.
## Implementation Order
1. Add `ScheduleCache` class (new file: `plugin/calendar/schedule-cache.ts`)
2. Add `getDaySchedule()` to `CalendarBridgeClient`
3. Refactor `CalendarScheduler`:
- Replace single interval with sync + check intervals
- Use cache instead of heartbeat for slot discovery
- Keep heartbeat for agent liveness reporting (reduced frequency)
4. Update state persistence for new structure
5. Keep existing wakeAgent/completion/abort/pause/resume tools unchanged
## Files to Modify
| File | Changes |
|------|---------|
| `plugin/calendar/schedule-cache.ts` | New file |
| `plugin/calendar/calendar-bridge.ts` | Add `getDaySchedule()` |
| `plugin/calendar/scheduler.ts` | Refactor heartbeat → sync + check |
| `plugin/calendar/index.ts` | Export new types |
## Risk Assessment
- **Low risk:** ScheduleCache is additive, doesn't break existing behavior
- **Medium risk:** Splitting heartbeat changes core scheduling logic
- **Mitigation:** Keep `heartbeat()` method intact, use it for liveness reporting alongside new sync

View File

@@ -169,32 +169,6 @@ export class CalendarBridgeClient {
return this.sendBoolean('POST', url, body); 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;
}
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Internal helpers // Internal helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -1,172 +0,0 @@
/**
* Discord-based agent wakeup: create a private channel and send a wakeup message.
*
* If Dirigent is detected (via globalThis.__dirigent), creates a work-type channel.
* Otherwise, creates a plain private Discord channel.
*/
const DISCORD_API = 'https://discord.com/api/v10';
interface WakeupConfig {
botToken: string;
guildId: string;
agentDiscordId?: string;
agentId: string;
message: string;
logger: {
info: (...args: any[]) => void;
warn: (...args: any[]) => void;
error: (...args: any[]) => void;
};
}
interface DirigentApi {
createWorkChannel?: (params: {
guildId: string;
name: string;
agentDiscordId: string;
}) => Promise<string>;
}
/**
* Get bot user ID from token (decode JWT-like Discord token).
*/
function getBotUserIdFromToken(token: string): string | null {
try {
const base64 = token.split('.')[0];
const decoded = Buffer.from(base64, 'base64').toString('utf8');
return decoded || null;
} catch {
return null;
}
}
/**
* Create a private Discord channel visible only to the target agent and bot.
*/
async function createPrivateChannel(
token: string,
guildId: string,
name: string,
memberIds: string[],
logger: WakeupConfig['logger']
): Promise<string | null> {
const botId = getBotUserIdFromToken(token);
// Permission overwrites: deny @everyone, allow specific members
const permissionOverwrites = [
{ id: guildId, type: 0, deny: '1024' }, // deny @everyone view
...memberIds.map(id => ({ id, type: 1, allow: '1024' })), // allow members view
...(botId ? [{ id: botId, type: 1, allow: '1024' }] : []),
];
try {
const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, {
method: 'POST',
headers: {
'Authorization': `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name,
type: 0, // text channel
permission_overwrites: permissionOverwrites,
}),
});
if (!res.ok) {
logger.warn(`Discord channel creation failed: ${res.status} ${await res.text()}`);
return null;
}
const data = await res.json() as { id: string };
return data.id;
} catch (err) {
logger.error(`Discord channel creation error: ${String(err)}`);
return null;
}
}
/**
* Send a message to a Discord channel.
*/
async function sendMessage(
token: string,
channelId: string,
content: string,
logger: WakeupConfig['logger']
): Promise<boolean> {
try {
const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
method: 'POST',
headers: {
'Authorization': `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ content }),
});
if (!res.ok) {
logger.warn(`Discord message send failed: ${res.status}`);
return false;
}
return true;
} catch (err) {
logger.error(`Discord message send error: ${String(err)}`);
return false;
}
}
/**
* Wake an agent via Discord: create a private channel and send the wakeup message.
*/
export async function wakeAgentViaDiscord(config: WakeupConfig): Promise<boolean> {
const { botToken, guildId, agentDiscordId, agentId, message, logger } = config;
if (!botToken || !guildId) {
logger.warn('Discord wakeup: botToken or guildId not configured');
return false;
}
// Check if Dirigent is available for work channel creation
const dirigent = (globalThis as Record<string, unknown>)['__dirigent'] as DirigentApi | undefined;
let channelId: string | null = null;
const channelName = `hf-wakeup-${agentId}-${Date.now()}`;
if (dirigent?.createWorkChannel && agentDiscordId) {
// Use Dirigent to create a work-type channel (with turn management)
try {
channelId = await dirigent.createWorkChannel({
guildId,
name: channelName,
agentDiscordId,
});
logger.info(`Wakeup channel created via Dirigent: ${channelId}`);
} catch (err) {
logger.warn(`Dirigent work channel creation failed, falling back to plain channel: ${String(err)}`);
}
}
if (!channelId) {
// Fallback: create a plain private Discord channel
const memberIds = agentDiscordId ? [agentDiscordId] : [];
channelId = await createPrivateChannel(botToken, guildId, channelName, memberIds, logger);
if (channelId) {
logger.info(`Wakeup channel created (plain): ${channelId}`);
}
}
if (!channelId) {
logger.error('Failed to create wakeup channel');
return false;
}
// Send the wakeup message
const sent = await sendMessage(botToken, channelId, message, logger);
if (sent) {
logger.info(`Wakeup message sent to ${channelId} for agent ${agentId}`);
}
return sent;
}

View File

@@ -31,5 +31,3 @@
export * from './types'; export * from './types';
export * from './calendar-bridge'; export * from './calendar-bridge';
export * from './scheduler'; export * from './scheduler';
export * from './schedule-cache';
export * from './discord-wakeup';

View File

@@ -1,109 +0,0 @@
/**
* Local cache of today's calendar schedule.
* Synced periodically from HF backend, checked locally for due slots.
*/
import type { CalendarSlotResponse } from "./types.js";
export class ScheduleCache {
private slots: Map<string, CalendarSlotResponse> = new Map();
private lastSyncAt: Date | null = null;
private cachedDate: string | null = null; // YYYY-MM-DD
/**
* Replace the cache with a fresh schedule from backend.
*/
sync(date: string, slots: CalendarSlotResponse[]): void {
// If date changed, clear old data
if (this.cachedDate !== date) {
this.slots.clear();
}
this.cachedDate = date;
// Merge: update existing slots, add new ones
const incomingIds = new Set<string>();
for (const slot of slots) {
const id = this.getSlotId(slot);
incomingIds.add(id);
this.slots.set(id, slot);
}
// Remove slots that no longer exist on backend (cancelled etc.)
for (const id of this.slots.keys()) {
if (!incomingIds.has(id)) {
this.slots.delete(id);
}
}
this.lastSyncAt = new Date();
}
/**
* Get slots that are due (scheduled_at <= now) and still pending.
*/
getDueSlots(now: Date): CalendarSlotResponse[] {
const results: CalendarSlotResponse[] = [];
for (const slot of this.slots.values()) {
if (slot.status !== "not_started" && slot.status !== "deferred") continue;
if (!slot.scheduled_at) continue;
const scheduledAt = this.parseScheduledTime(slot.scheduled_at);
if (scheduledAt && scheduledAt <= now) {
results.push(slot);
}
}
// Sort by priority descending
results.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
return results;
}
/**
* Update a slot locally (e.g., after status change).
*/
updateSlot(slotId: string, update: Partial<CalendarSlotResponse>): void {
const existing = this.slots.get(slotId);
if (existing) {
this.slots.set(slotId, { ...existing, ...update });
}
}
/**
* Remove a slot from cache.
*/
removeSlot(slotId: string): void {
this.slots.delete(slotId);
}
/**
* Get all cached slots.
*/
getAll(): CalendarSlotResponse[] {
return Array.from(this.slots.values());
}
/**
* Get cache metadata.
*/
getStatus(): { slotCount: number; lastSyncAt: string | null; cachedDate: string | null } {
return {
slotCount: this.slots.size,
lastSyncAt: this.lastSyncAt?.toISOString() ?? null,
cachedDate: this.cachedDate,
};
}
private getSlotId(slot: CalendarSlotResponse): string {
return slot.virtual_id ?? String(slot.id);
}
private parseScheduledTime(scheduledAt: string): Date | null {
// scheduled_at can be "HH:MM:SS" (time only) or full ISO
if (/^\d{2}:\d{2}(:\d{2})?$/.test(scheduledAt)) {
// Time-only: combine with cached date
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

@@ -19,7 +19,6 @@
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { CalendarBridgeClient } from './calendar-bridge'; import { CalendarBridgeClient } from './calendar-bridge';
import { ScheduleCache } from './schedule-cache';
import { import {
CalendarSlotResponse, CalendarSlotResponse,
SlotStatus, SlotStatus,
@@ -45,8 +44,6 @@ export interface CalendarSchedulerConfig {
}; };
/** Heartbeat interval in milliseconds (default: 60000) */ /** Heartbeat interval in milliseconds (default: 60000) */
heartbeatIntervalMs?: number; heartbeatIntervalMs?: number;
/** Schedule sync interval in milliseconds (default: 300000 = 5 min) */
syncIntervalMs?: number;
/** Enable verbose debug logging */ /** Enable verbose debug logging */
debug?: boolean; debug?: boolean;
/** Directory for state persistence (default: plugin data dir) */ /** Directory for state persistence (default: plugin data dir) */
@@ -98,10 +95,8 @@ interface SchedulerState {
currentSlot: CalendarSlotResponse | null; currentSlot: CalendarSlotResponse | null;
/** Last heartbeat timestamp */ /** Last heartbeat timestamp */
lastHeartbeatAt: Date | null; lastHeartbeatAt: Date | null;
/** Heartbeat interval handle */ /** Interval handle for cleanup */
intervalHandle: ReturnType<typeof setInterval> | null; intervalHandle: ReturnType<typeof setInterval> | null;
/** Schedule sync interval handle */
syncIntervalHandle: ReturnType<typeof setInterval> | null;
/** Set of slot IDs that have been deferred in current session */ /** Set of slot IDs that have been deferred in current session */
deferredSlotIds: Set<string>; deferredSlotIds: Set<string>;
/** Whether agent is currently processing a slot */ /** Whether agent is currently processing a slot */
@@ -122,13 +117,10 @@ export class CalendarScheduler {
private config: Required<CalendarSchedulerConfig>; private config: Required<CalendarSchedulerConfig>;
private state: SchedulerState; private state: SchedulerState;
private stateFilePath: string; private stateFilePath: string;
/** Local cache of today's full schedule, synced periodically from backend */
private scheduleCache: ScheduleCache = new ScheduleCache();
constructor(config: CalendarSchedulerConfig) { constructor(config: CalendarSchedulerConfig) {
this.config = { this.config = {
heartbeatIntervalMs: 60000, // 1 minute default heartbeatIntervalMs: 60000, // 1 minute default
syncIntervalMs: 300_000, // 5 minutes default
debug: false, debug: false,
stateDir: this.getDefaultStateDir(), stateDir: this.getDefaultStateDir(),
...config, ...config,
@@ -141,7 +133,6 @@ export class CalendarScheduler {
currentSlot: null, currentSlot: null,
lastHeartbeatAt: null, lastHeartbeatAt: null,
intervalHandle: null, intervalHandle: null,
syncIntervalHandle: null,
deferredSlotIds: new Set(), deferredSlotIds: new Set(),
isProcessing: false, isProcessing: false,
isRestartPending: false, isRestartPending: false,
@@ -336,21 +327,14 @@ export class CalendarScheduler {
this.state.isRestartPending = false; this.state.isRestartPending = false;
this.config.logger.info('Calendar scheduler started'); this.config.logger.info('Calendar scheduler started');
// Run initial sync + heartbeat immediately // Run initial heartbeat immediately
this.runSync();
this.runHeartbeat(); this.runHeartbeat();
// Schedule periodic heartbeats (slot execution checks) // Schedule periodic heartbeats
this.state.intervalHandle = setInterval( this.state.intervalHandle = setInterval(
() => this.runHeartbeat(), () => this.runHeartbeat(),
this.config.heartbeatIntervalMs this.config.heartbeatIntervalMs
); );
// Schedule periodic schedule sync (full day schedule refresh)
this.state.syncIntervalHandle = setInterval(
() => this.runSync(),
this.config.syncIntervalMs
);
} }
/** /**
@@ -364,41 +348,10 @@ export class CalendarScheduler {
clearInterval(this.state.intervalHandle); clearInterval(this.state.intervalHandle);
this.state.intervalHandle = null; this.state.intervalHandle = null;
} }
if (this.state.syncIntervalHandle) {
clearInterval(this.state.syncIntervalHandle);
this.state.syncIntervalHandle = null;
}
this.config.logger.info('Calendar scheduler stopped'); this.config.logger.info('Calendar scheduler stopped');
} }
/**
* Sync today's full schedule from backend into local cache.
* Runs every syncIntervalMs (default: 5 min).
* Catches new slots assigned by other agents or the manager.
*/
async runSync(): Promise<void> {
if (!this.state.isRunning || this.state.isRestartPending) return;
const today = new Date().toISOString().slice(0, 10);
try {
const slots = await this.config.bridge.getDaySchedule(today);
if (slots) {
this.scheduleCache.sync(today, slots);
this.logDebug(`Schedule synced: ${slots.length} slots for ${today}`);
}
} catch (err) {
this.config.logger.warn(`Schedule sync failed: ${String(err)}`);
}
}
/**
* Get the local schedule cache (for status reporting / tools).
*/
getScheduleCache(): ScheduleCache {
return this.scheduleCache;
}
/** /**
* Execute a single heartbeat cycle. * Execute a single heartbeat cycle.
* Fetches pending slots and handles execution logic. * Fetches pending slots and handles execution logic.
@@ -658,11 +611,13 @@ Task Code: ${code}
Estimated Duration: ${duration} minutes Estimated Duration: ${duration} minutes
Slot Type: ${slot.slot_type} Slot Type: ${slot.slot_type}
Priority: ${slot.priority} Priority: ${slot.priority}
Working Sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'}
Follow the daily-routine skill's task-handson workflow to execute this task. Please focus on this task for the allocated time. When you finish or need to pause,
Use harborforge_calendar_complete when finished, or harborforge_calendar_pause to pause. report your progress back to the calendar system.
Before going idle, check for overdue slots as described in the slot-complete workflow.`;
Working sessions: ${jobData.working_sessions?.join(', ') || 'none recorded'}
Start working on ${code} now.`;
} }
/** /**
@@ -675,15 +630,19 @@ Before going idle, check for overdue slots as described in the slot-complete wor
switch (sysData.event) { switch (sysData.event) {
case 'ScheduleToday': case 'ScheduleToday':
return `System Event: Schedule Today return `System Event: Schedule Today
Please review today's calendar and schedule any pending tasks or planning activities.
Estimated time: ${slot.estimated_duration} minutes. Estimated time: ${slot.estimated_duration} minutes.
Follow the daily-routine skill's plan-schedule workflow to plan today's work.`; Check your calendar and plan the day's work.`;
case 'SummaryToday': case 'SummaryToday':
return `System Event: Daily Summary return `System Event: Daily Summary
Please provide a summary of today's activities and progress.
Estimated time: ${slot.estimated_duration} minutes. Estimated time: ${slot.estimated_duration} minutes.
Review today's completed, deferred, and abandoned slots. Write a summary to your daily note (memory/YYYY-MM-DD.md).`; Review what was accomplished and prepare end-of-day notes.`;
case 'ScheduledGatewayRestart': case 'ScheduledGatewayRestart':
return `System Event: Scheduled Gateway Restart return `System Event: Scheduled Gateway Restart

View File

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

View File

@@ -14,7 +14,7 @@
import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os'; import { hostname, freemem, totalmem, uptime, loadavg, platform } from 'os';
import { getPluginConfig } from './core/config'; import { getPluginConfig } from './core/config';
import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge'; import { MonitorBridgeClient, type OpenClawMeta } from './core/monitor-bridge';
import { listOpenClawAgents } from './core/openclaw-agents'; import type { OpenClawAgentInfo } from './core/openclaw-agents';
import { registerGatewayStartHook } from './hooks/gateway-start'; import { registerGatewayStartHook } from './hooks/gateway-start';
import { registerGatewayStopHook } from './hooks/gateway-stop'; import { registerGatewayStopHook } from './hooks/gateway-stop';
import { import {
@@ -32,6 +32,12 @@ interface PluginAPI {
warn: (...args: any[]) => void; warn: (...args: any[]) => void;
}; };
version?: string; version?: string;
runtime?: {
version?: string;
config?: {
loadConfig?: () => any;
};
};
config?: Record<string, unknown>; config?: Record<string, unknown>;
pluginConfig?: Record<string, unknown>; pluginConfig?: Record<string, unknown>;
on: (event: string, handler: () => void) => void; on: (event: string, handler: () => void) => void;
@@ -96,7 +102,7 @@ export default {
avg15: load[2], avg15: load[2],
}, },
openclaw: { openclaw: {
version: api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
pluginVersion: '0.3.1', // Bumped for PLG-CAL-004 pluginVersion: '0.3.1', // Bumped for PLG-CAL-004
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -118,10 +124,21 @@ export default {
const bridgeClient = getBridgeClient(); const bridgeClient = getBridgeClient();
if (!bridgeClient) return; 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 = { const meta: OpenClawMeta = {
version: api.version || 'unknown', version: api.runtime?.version || api.version || 'unknown',
plugin_version: '0.3.1', plugin_version: '0.3.1',
agents: await listOpenClawAgents(logger), agents: agentNames.map(name => ({ name })),
}; };
const ok = await bridgeClient.pushOpenClawMeta(meta); const ok = await bridgeClient.pushOpenClawMeta(meta);
@@ -171,52 +188,53 @@ export default {
} }
/** /**
* Wake agent via Discord channel creation + message. * Wake/spawn agent with task context for slot execution.
* This is the callback invoked by CalendarScheduler when a slot is ready. * This is the callback invoked by CalendarScheduler when a slot is ready.
*
* Priority:
* 1. Discord wakeup (create private channel + send message)
* 2. OpenClaw spawn API (fallback if Discord not configured)
*/ */
async function wakeAgent(context: AgentWakeContext): Promise<boolean> { async function wakeAgent(context: AgentWakeContext): Promise<boolean> {
logger.info(`Waking agent for slot: ${context.taskDescription}`); logger.info(`Waking agent for slot: ${context.taskDescription}`);
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
try { try {
// Method 1: Discord wakeup (preferred) // Method 1: Use OpenClaw spawn API if available (preferred)
const discordBotToken = (live as any).discordBotToken as string | undefined;
const discordGuildId = (live as any).discordGuildId as string | undefined;
if (discordBotToken && discordGuildId) {
const { wakeAgentViaDiscord } = await import('./calendar/discord-wakeup.js');
const success = await wakeAgentViaDiscord({
botToken: discordBotToken,
guildId: discordGuildId,
agentId,
message: context.prompt,
logger,
});
if (success) return true;
logger.warn('Discord wakeup failed, trying spawn fallback');
}
// Method 2: OpenClaw spawn API (fallback)
if (api.spawn) { if (api.spawn) {
const result = await api.spawn({ const result = await api.spawn({
task: context.prompt, task: context.prompt,
timeoutSeconds: context.slot.estimated_duration * 60, timeoutSeconds: context.slot.estimated_duration * 60, // Convert to seconds
}); });
if (result?.sessionId) { if (result?.sessionId) {
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`);
// Track session completion
trackSessionCompletion(result.sessionId, context); trackSessionCompletion(result.sessionId, context);
return true; return true;
} }
} }
logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)'); // Method 2: Send notification/alert to wake agent (fallback)
return false; // 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) { } catch (err) {
logger.error('Failed to wake agent:', err); logger.error('Failed to wake agent:', err);

View File

@@ -63,14 +63,6 @@
"managedMonitor": { "managedMonitor": {
"type": "string", "type": "string",
"description": "Absolute path to an installed HarborForge.Monitor binary managed by this plugin installer. If set, gateway_start/gateway_stop hooks will start/stop the monitor process automatically." "description": "Absolute path to an installed HarborForge.Monitor binary managed by this plugin installer. If set, gateway_start/gateway_stop hooks will start/stop the monitor process automatically."
},
"discordBotToken": {
"type": "string",
"description": "Discord bot token for agent wakeup. Used to create private channels and send wakeup messages. Set to the same value as Dirigent moderator bot token."
},
"discordGuildId": {
"type": "string",
"description": "Discord guild ID where wakeup channels are created."
} }
} }
} }