|
|
|
|
@@ -25,6 +25,25 @@ import {
|
|
|
|
|
CalendarScheduler,
|
|
|
|
|
} from './calendar/index.js';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Module-scope calendar scheduler singleton.
|
|
|
|
|
//
|
|
|
|
|
// `register()` is called multiple times per gateway boot — once per agent
|
|
|
|
|
// (we see 5 `HarborForge plugin registered` lines for 5 agents on dind-t2).
|
|
|
|
|
// `gateway_start` only fires once, so before this lift the
|
|
|
|
|
// `startCalendarScheduler()` setup ran inside ONE closure while four other
|
|
|
|
|
// closures kept their own `calendarScheduler = null`. Whichever of the five
|
|
|
|
|
// tool registrations the gateway picked at call time was effectively a coin
|
|
|
|
|
// flip, and four times out of five `harborforge_calendar_status` returned
|
|
|
|
|
// `Calendar scheduler not running` even though the scheduler was active.
|
|
|
|
|
//
|
|
|
|
|
// Keeping the singleton at module scope removes the per-`register()` shadow:
|
|
|
|
|
// the scheduler is started once, every closure reads the same binding, and
|
|
|
|
|
// `startCalendarScheduler()` is idempotent so duplicate `gateway_start`
|
|
|
|
|
// firings are harmless.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
let calendarScheduler: MultiAgentSchedulerHandle | CalendarScheduler | null = null;
|
|
|
|
|
|
|
|
|
|
interface PluginAPI {
|
|
|
|
|
logger: {
|
|
|
|
|
info: (...args: any[]) => void;
|
|
|
|
|
@@ -54,6 +73,30 @@ interface PluginAPI {
|
|
|
|
|
getAgentStatus?: () => Promise<{ status: string } | null>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Coerce a tool execute() return value into the MCP `{ content: [...] }`
|
|
|
|
|
* shape that the openclaw Codex tool dispatcher requires.
|
|
|
|
|
*
|
|
|
|
|
* Background: openclaw's `convertToolContents()` does `result.content.reduce(...)`
|
|
|
|
|
* to compute total text length before flattening. Every HF tool here returned a
|
|
|
|
|
* bare object (`{ running, processing, currentSlot, ... }`) which has no
|
|
|
|
|
* `.content` field, so `undefined.reduce` threw and every call to
|
|
|
|
|
* `harborforge_*` from a Codex-harness agent surfaced as the cryptic
|
|
|
|
|
* `Cannot read properties of undefined (reading 'reduce')`. The fix is to
|
|
|
|
|
* wrap every tool's execute return; doing it at the `registerTool` boundary
|
|
|
|
|
* keeps each tool body unchanged.
|
|
|
|
|
*/
|
|
|
|
|
function ensureMcpContentShape(result: unknown): { content: Array<{ type: 'text'; text: string }> } {
|
|
|
|
|
if (
|
|
|
|
|
result && typeof result === 'object' &&
|
|
|
|
|
Array.isArray((result as { content?: unknown }).content)
|
|
|
|
|
) {
|
|
|
|
|
return result as { content: Array<{ type: 'text'; text: string }> };
|
|
|
|
|
}
|
|
|
|
|
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
|
|
|
|
return { content: [{ type: 'text', text }] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function register(api: PluginAPI): void {
|
|
|
|
|
const logger = api.logger || {
|
|
|
|
|
info: (...args: any[]) => console.log('[HarborForge]', ...args),
|
|
|
|
|
@@ -62,6 +105,22 @@ function register(api: PluginAPI): void {
|
|
|
|
|
warn: (...args: any[]) => console.warn('[HarborForge]', ...args),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Wrap api.registerTool so every tool's execute() return is coerced into
|
|
|
|
|
// the MCP `{ content: [...] }` shape openclaw expects. See
|
|
|
|
|
// `ensureMcpContentShape` above.
|
|
|
|
|
const _origRegisterTool = api.registerTool.bind(api);
|
|
|
|
|
api.registerTool = (factory: (ctx: any) => any) => {
|
|
|
|
|
_origRegisterTool((ctx: any) => {
|
|
|
|
|
const def = factory(ctx);
|
|
|
|
|
if (!def || typeof def.execute !== 'function') return def;
|
|
|
|
|
const origExecute = def.execute;
|
|
|
|
|
return {
|
|
|
|
|
...def,
|
|
|
|
|
execute: async (...args: any[]) => ensureMcpContentShape(await origExecute(...args)),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function resolveConfig() {
|
|
|
|
|
return getPluginConfig(api);
|
|
|
|
|
}
|
|
|
|
|
@@ -69,7 +128,9 @@ function register(api: PluginAPI): void {
|
|
|
|
|
/** 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?.();
|
|
|
|
|
// Read from cached `api.config` first — see pushMetaToMonitor for why
|
|
|
|
|
// the deprecated `api.runtime?.config?.loadConfig?.()` path is heavy.
|
|
|
|
|
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
|
|
|
|
|
return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -108,7 +169,7 @@ function register(api: PluginAPI): void {
|
|
|
|
|
},
|
|
|
|
|
openclaw: {
|
|
|
|
|
version: api.runtime?.version || api.version || 'unknown',
|
|
|
|
|
pluginVersion: '0.3.2', // Bumped for PLG-CAL-004
|
|
|
|
|
pluginVersion: '0.3.4', // Bumped for PLG-CAL-004
|
|
|
|
|
},
|
|
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
@@ -117,18 +178,33 @@ function register(api: PluginAPI): void {
|
|
|
|
|
// Periodic metadata push interval handle
|
|
|
|
|
let metaPushInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
|
|
|
|
|
// Calendar scheduler instance.
|
|
|
|
|
//
|
|
|
|
|
// In multi-agent sync mode (the only path today) this is a
|
|
|
|
|
// {@link MultiAgentSchedulerHandle}. The legacy `CalendarScheduler` type
|
|
|
|
|
// is retained in the union for compatibility with the typed-only single-
|
|
|
|
|
// agent path that may be reintroduced later.
|
|
|
|
|
let calendarScheduler: MultiAgentSchedulerHandle | CalendarScheduler | null = null;
|
|
|
|
|
// (calendarScheduler is module-scope — see top of file for the why.
|
|
|
|
|
// Tools and lifecycle hooks all reference the same binding so the
|
|
|
|
|
// multi-register/single-start mismatch can't shadow them again.)
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Push OpenClaw metadata to the Monitor bridge.
|
|
|
|
|
* This enriches Monitor heartbeats with OpenClaw version/plugin/agent info.
|
|
|
|
|
* Failures are non-fatal — Monitor continues to work without this data.
|
|
|
|
|
*
|
|
|
|
|
* IMPORTANT — read config from the cached `api.config` surface, NOT from
|
|
|
|
|
* the deprecated `api.runtime?.config?.loadConfig?.()` path. The
|
|
|
|
|
* deprecated path triggers a full plugin-metadata-snapshot rebuild on
|
|
|
|
|
* every call: realpathSync walks every plugin's package.json + manifest
|
|
|
|
|
* + source paths (lstats up the directory tree), `hashWatchedFiles`
|
|
|
|
|
* fingerprints all watched plugin files, and `discoverInDirectory`
|
|
|
|
|
* re-scans every `dist/extensions/<plugin>` dir. On t2 with ~100 plugins
|
|
|
|
|
* each rebuild costs ~6-7s of CPU; with this push firing every 30s
|
|
|
|
|
* (default reportIntervalSec) the chronic baseline was ~22-25% gateway
|
|
|
|
|
* CPU even with zero agent activity (V8 profile 2026-05-27 08:14:00 60s:
|
|
|
|
|
* lstat 44.2%, statSync 6.9%, hashWatchedFiles via memo key 1.7%, all
|
|
|
|
|
* routed through readPersistedInstalledPluginIndexInstallRecordsSync ->
|
|
|
|
|
* discoverInDirectory). Switching to `api.config` reads from the
|
|
|
|
|
* already-loaded snapshot cache; the elsewhere-in-this-file pattern was
|
|
|
|
|
* already `api.config ?? api.runtime?.config?.loadConfig?.()`.
|
|
|
|
|
*
|
|
|
|
|
* Same fix is applied to `resolveAgentId` below — that's read once at
|
|
|
|
|
* gateway start so the impact is smaller, but it's the same anti-pattern.
|
|
|
|
|
*/
|
|
|
|
|
async function pushMetaToMonitor() {
|
|
|
|
|
const bridgeClient = getBridgeClient();
|
|
|
|
|
@@ -136,7 +212,7 @@ function register(api: PluginAPI): void {
|
|
|
|
|
|
|
|
|
|
let agentNames: string[] = [];
|
|
|
|
|
try {
|
|
|
|
|
const cfg = api.runtime?.config?.loadConfig?.();
|
|
|
|
|
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
|
|
|
|
|
const agentsList = cfg?.agents?.list;
|
|
|
|
|
if (Array.isArray(agentsList)) {
|
|
|
|
|
agentNames = agentsList
|
|
|
|
|
@@ -147,7 +223,7 @@ function register(api: PluginAPI): void {
|
|
|
|
|
|
|
|
|
|
const meta: OpenClawMeta = {
|
|
|
|
|
version: api.runtime?.version || api.version || 'unknown',
|
|
|
|
|
plugin_version: '0.3.2',
|
|
|
|
|
plugin_version: '0.3.4',
|
|
|
|
|
agents: agentNames.map(name => ({ name })),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
@@ -252,10 +328,22 @@ function register(api: PluginAPI): void {
|
|
|
|
|
)}\n\`\`\``;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The wakeup dispatcher's `deliver` callback below only logs the
|
|
|
|
|
// reply text — it does NOT inspect any ack token. The earlier
|
|
|
|
|
// `WAKEUP_OK` first-line-ack convention was prompt-only theatre;
|
|
|
|
|
// nothing in this plugin or in openclaw acted on it. The only
|
|
|
|
|
// thing that ends a wake cycle is the slot transitioning out of
|
|
|
|
|
// `not_started`, which happens when the agent calls
|
|
|
|
|
// `harborforge_calendar_complete` or `harborforge_calendar_abort`.
|
|
|
|
|
// Tell the agent that plainly instead of asking for a fake ack.
|
|
|
|
|
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.${slotBlock}`;
|
|
|
|
|
`You have due slots. Drive the \`hf-wakeup\` workflow of skill ` +
|
|
|
|
|
`\`hf-hangman-lab\` to completion in this session — read slot ` +
|
|
|
|
|
`context, call the harborforge_calendar_* tools, route to the ` +
|
|
|
|
|
`right sub-workflow, and finish with harborforge_calendar_complete ` +
|
|
|
|
|
`or harborforge_calendar_abort. The scheduler keeps re-waking you ` +
|
|
|
|
|
`every 30s until the slot transitions out of \`not_started\`, so ` +
|
|
|
|
|
`partial work or silence just produces another wake.${slotBlock}`;
|
|
|
|
|
|
|
|
|
|
const result = await dispatchInboundMessageWithDispatcher({
|
|
|
|
|
ctx: {
|
|
|
|
|
@@ -291,8 +379,16 @@ function register(api: PluginAPI): void {
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Initialize and start the calendar scheduler.
|
|
|
|
|
*
|
|
|
|
|
* Idempotent — `gateway_start` may fire once per `register()` invocation
|
|
|
|
|
* (the host calls `register` per agent), and we only want one set of
|
|
|
|
|
* sync/check intervals across the whole process.
|
|
|
|
|
*/
|
|
|
|
|
function startCalendarScheduler(): void {
|
|
|
|
|
if (calendarScheduler) {
|
|
|
|
|
logger.info('Calendar scheduler already started, skipping duplicate gateway_start');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const live = resolveConfig();
|
|
|
|
|
|
|
|
|
|
// Create bridge client (claw-instance level, not per-agent)
|
|
|
|
|
@@ -322,6 +418,94 @@ function register(api: PluginAPI): void {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cross-plugin exposure: agent status lookup for other plugins
|
|
|
|
|
// (currently Fabric.OpenclawPlugin uses this to skip delivering
|
|
|
|
|
// `announce` channel messages to busy agents — see DIALECTIC-V2
|
|
|
|
|
// design doc, Phase 1). Backed by calendarBridge.getAgentStatus
|
|
|
|
|
// with a small TTL cache to avoid hammering the HF backend.
|
|
|
|
|
type HfStatus = 'idle' | 'on_call' | 'busy' | 'exhausted' | 'offline';
|
|
|
|
|
const HF_STATUS_CACHE_TTL_MS = 30_000;
|
|
|
|
|
const hfStatusCache = new Map<string, { status: HfStatus; at: number }>();
|
|
|
|
|
const _G = globalThis as Record<string, unknown>;
|
|
|
|
|
_G['__hfAgentStatus'] = {
|
|
|
|
|
async get(agentId: string): Promise<HfStatus | undefined> {
|
|
|
|
|
if (!agentId) return undefined;
|
|
|
|
|
const cached = hfStatusCache.get(agentId);
|
|
|
|
|
if (cached && Date.now() - cached.at < HF_STATUS_CACHE_TTL_MS) {
|
|
|
|
|
return cached.status;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const status = await calendarBridge.getAgentStatus(agentId);
|
|
|
|
|
if (status) {
|
|
|
|
|
const typed = status as HfStatus;
|
|
|
|
|
hfStatusCache.set(agentId, { status: typed, at: Date.now() });
|
|
|
|
|
return typed;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
/* fall through to cached-or-undefined */
|
|
|
|
|
}
|
|
|
|
|
return cached?.status;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Approximate "does agent have an on_call slot covering [from, to]?"
|
|
|
|
|
* for cross-plugin pre-check use (currently:
|
|
|
|
|
* Dialectic.OpenclawPlugin's signup HF coverage).
|
|
|
|
|
*
|
|
|
|
|
* v1 honest scope: we only have today's slots in scheduleCache
|
|
|
|
|
* (synced from /calendar/sync which is today-only). Returns:
|
|
|
|
|
* - true iff window is same-day AND some cached on_call slot
|
|
|
|
|
* starts <= from AND ends >= to
|
|
|
|
|
* - false iff window is same-day AND no such slot
|
|
|
|
|
* - undefined for cross-day windows OR cache empty for this
|
|
|
|
|
* agent (caller treats undefined as "I don't know" — see
|
|
|
|
|
* Dialectic plugin's hf-precheck.ts which degrades to
|
|
|
|
|
* "skipped" gracefully)
|
|
|
|
|
*
|
|
|
|
|
* Phase TBD: when HF backend ships a `/calendar/slots?agent&from&to`
|
|
|
|
|
* endpoint, swap this to call it for arbitrary windows. Until then,
|
|
|
|
|
* same-day-only coverage gates ~all debates created by analyze-intel
|
|
|
|
|
* (which schedules <2h windows) without needing a backend change.
|
|
|
|
|
*/
|
|
|
|
|
async hasOnCallCovering(
|
|
|
|
|
agentId: string,
|
|
|
|
|
fromIso: string,
|
|
|
|
|
toIso: string,
|
|
|
|
|
): Promise<boolean | undefined> {
|
|
|
|
|
if (!agentId || !fromIso || !toIso) return undefined;
|
|
|
|
|
const from = new Date(fromIso);
|
|
|
|
|
const to = new Date(toIso);
|
|
|
|
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) return undefined;
|
|
|
|
|
if (!(from < to)) return undefined;
|
|
|
|
|
// Cross-day → cache only has today; can't decide.
|
|
|
|
|
const fromDate = from.toISOString().slice(0, 10);
|
|
|
|
|
const toDate = to.toISOString().slice(0, 10);
|
|
|
|
|
if (fromDate !== toDate) return undefined;
|
|
|
|
|
// Cache's cachedDate must match our window's date.
|
|
|
|
|
const cacheStatus = scheduleCache.getStatus();
|
|
|
|
|
if (cacheStatus.cachedDate !== fromDate) return undefined;
|
|
|
|
|
const slots = scheduleCache.getAgentSlots(agentId);
|
|
|
|
|
if (slots.length === 0) return undefined; // cache empty for this agent — can't decide
|
|
|
|
|
for (const s of slots) {
|
|
|
|
|
if (s.slot_type !== 'on_call') continue;
|
|
|
|
|
// status: ignore aborted/cancelled, accept not_started / ongoing / finished
|
|
|
|
|
if (s.status === 'aborted' || s.status === 'cancelled') continue;
|
|
|
|
|
const startStr = s.scheduled_at;
|
|
|
|
|
if (typeof startStr !== 'string') continue;
|
|
|
|
|
// scheduled_at can be HH:MM:SS (cache-relative date) or full ISO
|
|
|
|
|
const start =
|
|
|
|
|
/^\d{2}:\d{2}(:\d{2})?$/.test(startStr)
|
|
|
|
|
? new Date(`${fromDate}T${startStr}Z`)
|
|
|
|
|
: new Date(startStr);
|
|
|
|
|
if (isNaN(start.getTime())) continue;
|
|
|
|
|
const dur = typeof s.estimated_duration === 'number' ? s.estimated_duration : 0;
|
|
|
|
|
const end = new Date(start.getTime() + dur * 60_000);
|
|
|
|
|
if (start <= from && end >= to) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Track wakes already dispatched for a slot in the current sync
|
|
|
|
|
// window — the simplified inline scheduler does not PATCH slot
|
|
|
|
|
// status server-side, so without dedupe the check loop re-wakes
|
|
|
|
|
|