Compare commits

..

12 Commits

Author SHA1 Message Date
operator
fb53af642d fix: pass gateway token via URL query parameter
Gateway may validate auth during HTTP upgrade before WebSocket
messages can be sent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 05:44:11 +00:00
operator
a6f63e15f4 fix: use 'connect' method with auth.token for gateway handshake
Gateway uses JSON-RPC method 'connect' (not 'hello') with auth
object containing token field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 05:40:41 +00:00
operator
a7d541dcc0 fix: pass gateway auth token in WebSocket hello
Read gateway.auth.token from config via api.runtime.config.loadConfig()
and include it in the hello handshake.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 05:36:08 +00:00
operator
1881ce6168 fix: use globalThis.WebSocket instead of ws module
Node 22+ has WebSocket built-in on globalThis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 05:32:11 +00:00
operator
ce61834738 feat: wake agent via gateway WebSocket API instead of api.spawn
Replace non-existent api.spawn with direct WebSocket call to
gateway "agent" method — the same mechanism used by sessions_spawn
and cron internally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 05:31:13 +00:00
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 117 additions and 274 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 './calendar-bridge';
export * from './scheduler'; export * from './scheduler';
export * from './schedule-cache'; 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 { 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 {
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 []; 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,55 +188,99 @@ export default {
} }
/** /**
* Wake agent via Discord channel creation + message. * Wake agent via gateway WebSocket API.
* This is the callback invoked by CalendarScheduler when a slot is ready. * Uses callGateway("agent") to trigger an agent turn — the same mechanism
* * used by sessions_spawn and cron internally.
* 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'; const agentId = process.env.AGENT_ID || 'unknown';
try { try {
// Method 1: Discord wakeup (preferred) // Connect to gateway via WebSocket and trigger an agent turn.
const discordBotToken = (live as any).discordBotToken as string | undefined; // Uses the same gateway RPC that sessions_spawn and cron use internally.
const discordGuildId = (live as any).discordGuildId as string | undefined; const cfg = api.runtime?.config?.loadConfig?.() ?? {};
const gwCfg = cfg.gateway ?? {};
const gwPort = gwCfg.port ?? 18789;
const gwToken = gwCfg.auth?.token ?? '';
const gatewayUrl = `ws://127.0.0.1:${gwPort}`;
if (discordBotToken && discordGuildId) { const result = await new Promise<{ sessionId?: string; error?: string }>((resolve, reject) => {
const { wakeAgentViaDiscord } = await import('./calendar/discord-wakeup.js'); const timeout = setTimeout(() => {
const success = await wakeAgentViaDiscord({ try { client.close(); } catch {}
botToken: discordBotToken, reject(new Error('Gateway connection timeout'));
guildId: discordGuildId, }, 15000);
agentId,
// Pass token via Authorization header in the upgrade request
const wsUrl = gwToken ? `${gatewayUrl}?token=${encodeURIComponent(gwToken)}` : gatewayUrl;
const client = new (globalThis as any).WebSocket(wsUrl);
client.onerror = (err: any) => {
clearTimeout(timeout);
reject(err?.error || err);
};
client.onopen = () => {
// Gateway uses "connect" method with auth in params
client.send(JSON.stringify({
jsonrpc: '2.0',
method: 'connect',
params: {
clientName: 'harbor-forge-calendar',
mode: 'backend',
...(gwToken ? { auth: { token: gwToken } } : {}),
},
id: 1,
}));
};
let helloAcked = false;
client.onmessage = (ev: any) => {
try {
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString());
if (!helloAcked && msg.id === 1) {
helloAcked = true;
client.send(JSON.stringify({
jsonrpc: '2.0',
method: 'agent',
params: {
message: context.prompt, message: context.prompt,
logger, agentId,
});
if (success) return true;
logger.warn('Discord wakeup failed, trying spawn fallback');
}
// Method 2: OpenClaw spawn API (fallback)
if (api.spawn) {
const result = await api.spawn({
task: context.prompt,
timeoutSeconds: context.slot.estimated_duration * 60, timeoutSeconds: context.slot.estimated_duration * 60,
},
id: 2,
}));
return;
}
if (msg.id === 2) {
clearTimeout(timeout);
try { client.close(); } catch {}
if (msg.error) {
resolve({ error: msg.error.message || JSON.stringify(msg.error) });
} else {
resolve({ sessionId: msg.result?.sessionId || 'ok' });
}
}
} catch {
// ignore parse errors
}
};
}); });
if (result?.sessionId) { if (result.error) {
logger.info(`Agent spawned for calendar slot: session=${result.sessionId}`); logger.error(`Gateway agent call failed: ${result.error}`);
trackSessionCompletion(result.sessionId, context); return false;
return true;
}
} }
logger.warn('No wakeup method available (configure discordBotToken + discordGuildId)'); logger.info(`Agent woken via gateway for slot: session=${result.sessionId}`);
return false; return true;
} catch (err) { } catch (err) {
logger.error('Failed to wake agent:', err); logger.error('Failed to wake agent via gateway:', err);
return false; return false;
} }
} }

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."
} }
} }
} }