Compare commits

..

7 Commits

Author SHA1 Message Date
operator
2d3d24b0ac Revert "feat: Discord-based agent wakeup replacing spawn"
This reverts commit be30b4b3f4.
2026-04-19 03:26:56 +00:00
operator
86c9d43c97 feat: Discord-based agent wakeup replacing spawn
New wakeup flow:
1. Create private Discord channel for the agent
2. Send wakeup message with slot context + workflow reference
3. If Dirigent detected (globalThis.__dirigent), create work-type channel
4. Fallback to api.spawn if Discord not configured

New config fields: discordBotToken, discordGuildId
New file: plugin/calendar/discord-wakeup.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 03:26:56 +00:00
operator
4a66a7cc90 feat: add local schedule cache + periodic sync to CalendarScheduler
- New ScheduleCache class: maintains today's full schedule locally
- CalendarBridgeClient.getDaySchedule(): fetch all slots for a date
- Scheduler now runs two intervals:
  - Heartbeat (60s): existing slot execution flow (unchanged)
  - Sync (5min): pulls full day schedule into local cache
- Exposes getScheduleCache() for tools and status reporting

This enables the plugin to detect slots assigned by other agents
between heartbeats and provides a complete local view of the schedule.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 03:26:56 +00:00
operator
9af8204376 feat: align wakeup prompts with daily-routine skill workflows
Update CalendarScheduler prompt templates to reference daily-routine
skill workflows (task-handson, plan-schedule, slot-complete) instead
of generic instructions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 03:26:56 +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
5 changed files with 64 additions and 264 deletions

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

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

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

@@ -14,7 +14,7 @@
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 type { OpenClawAgentInfo } from './core/openclaw-agents';
import { registerGatewayStartHook } from './hooks/gateway-start';
import { registerGatewayStopHook } from './hooks/gateway-stop';
import {
@@ -32,6 +32,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;
@@ -96,7 +102,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 +124,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);
@@ -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.
*
* 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> {
logger.info(`Waking agent for slot: ${context.taskDescription}`);
const live = resolveConfig();
const agentId = process.env.AGENT_ID || 'unknown';
try {
// Method 1: Discord wakeup (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)
// 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,
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;
}
}
logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)');
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');
// 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);

View File

@@ -63,14 +63,6 @@
"managedMonitor": {
"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."
},
"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."
}
}
}