Compare commits
9 Commits
main
...
a1b4d347d9
| Author | SHA1 | Date | |
|---|---|---|---|
| a1b4d347d9 | |||
| 2a2a298d15 | |||
|
|
102809dc2a | ||
| 065b0d3da3 | |||
|
|
afb8b25558 | ||
| 98e663a19b | |||
|
|
d5cea9a44d | ||
| f627845543 | |||
|
|
b878fa2a41 |
142
plugin/index.ts
142
plugin/index.ts
@@ -128,9 +128,7 @@ function register(api: PluginAPI): void {
|
|||||||
/** Resolve agent ID from env, config, or fallback. */
|
/** Resolve agent ID from env, config, or fallback. */
|
||||||
function resolveAgentId(): string {
|
function resolveAgentId(): string {
|
||||||
if (process.env.AGENT_ID) return process.env.AGENT_ID;
|
if (process.env.AGENT_ID) return process.env.AGENT_ID;
|
||||||
// Read from cached `api.config` first — see pushMetaToMonitor for why
|
const cfg = api.runtime?.config?.loadConfig?.();
|
||||||
// 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';
|
return cfg?.agents?.list?.[0]?.id ?? cfg?.agents?.defaults?.id ?? 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,25 +184,6 @@ function register(api: PluginAPI): void {
|
|||||||
* Push OpenClaw metadata to the Monitor bridge.
|
* Push OpenClaw metadata to the Monitor bridge.
|
||||||
* This enriches Monitor heartbeats with OpenClaw version/plugin/agent info.
|
* This enriches Monitor heartbeats with OpenClaw version/plugin/agent info.
|
||||||
* Failures are non-fatal — Monitor continues to work without this data.
|
* 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() {
|
async function pushMetaToMonitor() {
|
||||||
const bridgeClient = getBridgeClient();
|
const bridgeClient = getBridgeClient();
|
||||||
@@ -212,7 +191,7 @@ function register(api: PluginAPI): void {
|
|||||||
|
|
||||||
let agentNames: string[] = [];
|
let agentNames: string[] = [];
|
||||||
try {
|
try {
|
||||||
const cfg = (api as any).config ?? api.runtime?.config?.loadConfig?.();
|
const cfg = api.runtime?.config?.loadConfig?.();
|
||||||
const agentsList = cfg?.agents?.list;
|
const agentsList = cfg?.agents?.list;
|
||||||
if (Array.isArray(agentsList)) {
|
if (Array.isArray(agentsList)) {
|
||||||
agentNames = agentsList
|
agentNames = agentsList
|
||||||
@@ -328,22 +307,21 @@ function register(api: PluginAPI): void {
|
|||||||
)}\n\`\`\``;
|
)}\n\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wakeup dispatcher's `deliver` callback below only logs the
|
// First-line ack `WAKEUP_OK` is the plugin's ack-receipt token; the
|
||||||
// reply text — it does NOT inspect any ack token. The earlier
|
// agent MUST then continue in the same session and drive the
|
||||||
// `WAKEUP_OK` first-line-ack convention was prompt-only theatre;
|
// `hf-wakeup` workflow to completion (calendar_status → task fetch →
|
||||||
// nothing in this plugin or in openclaw acted on it. The only
|
// sub-workflow → calendar_complete/abort). Without that continuation
|
||||||
// thing that ends a wake cycle is the slot transitioning out of
|
// the scheduler keeps re-waking every 30s because the slot stays
|
||||||
// `not_started`, which happens when the agent calls
|
// `not_started` forever.
|
||||||
// `harborforge_calendar_complete` or `harborforge_calendar_abort`.
|
|
||||||
// Tell the agent that plainly instead of asking for a fake ack.
|
|
||||||
const wakeupMessage =
|
const wakeupMessage =
|
||||||
`You have due slots. Drive the \`hf-wakeup\` workflow of skill ` +
|
`You have due slots. **First line of your reply MUST be exactly ` +
|
||||||
`\`hf-hangman-lab\` to completion in this session — read slot ` +
|
`\`WAKEUP_OK\`** so the plugin records the ack. Then, **in this ` +
|
||||||
`context, call the harborforge_calendar_* tools, route to the ` +
|
`same session**, drive the \`hf-wakeup\` workflow of skill ` +
|
||||||
`right sub-workflow, and finish with harborforge_calendar_complete ` +
|
`\`hf-hangman-lab\` to completion — read slot context, call the ` +
|
||||||
`or harborforge_calendar_abort. The scheduler keeps re-waking you ` +
|
`harborforge_calendar_* tools, route to the right sub-workflow, ` +
|
||||||
`every 30s until the slot transitions out of \`not_started\`, so ` +
|
`and finish with harborforge_calendar_complete or abort. Do NOT ` +
|
||||||
`partial work or silence just produces another wake.${slotBlock}`;
|
`stop after the ack — the scheduler will re-wake you every 30s ` +
|
||||||
|
`until the slot transitions out of \`not_started\`.${slotBlock}`;
|
||||||
|
|
||||||
const result = await dispatchInboundMessageWithDispatcher({
|
const result = await dispatchInboundMessageWithDispatcher({
|
||||||
ctx: {
|
ctx: {
|
||||||
@@ -418,94 +396,6 @@ 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
|
// Track wakes already dispatched for a slot in the current sync
|
||||||
// window — the simplified inline scheduler does not PATCH slot
|
// window — the simplified inline scheduler does not PATCH slot
|
||||||
// status server-side, so without dedupe the check loop re-wakes
|
// status server-side, so without dedupe the check loop re-wakes
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ const OLD_PLUGIN_NAME = 'harborforge-monitor';
|
|||||||
const PLUGIN_SRC_DIR = join(__dirname, 'plugin');
|
const PLUGIN_SRC_DIR = join(__dirname, 'plugin');
|
||||||
const SKILLS_SRC_DIR = join(__dirname, 'skills');
|
const SKILLS_SRC_DIR = join(__dirname, 'skills');
|
||||||
const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git';
|
const MONITOR_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Monitor.git';
|
||||||
const CLI_REPO_URL = 'https://git.hangman-lab.top/zhi/HarborForge.Cli.git';
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const options = {
|
const options = {
|
||||||
@@ -44,7 +43,6 @@ const options = {
|
|||||||
installCli: args.includes('--install-cli'),
|
installCli: args.includes('--install-cli'),
|
||||||
installMonitor: 'no',
|
installMonitor: 'no',
|
||||||
monitorBranch: 'main',
|
monitorBranch: 'main',
|
||||||
cliBranch: 'main',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileIdx = args.indexOf('--openclaw-profile-path');
|
const profileIdx = args.indexOf('--openclaw-profile-path');
|
||||||
@@ -62,11 +60,6 @@ if (monitorBranchIdx !== -1 && args[monitorBranchIdx + 1]) {
|
|||||||
options.monitorBranch = String(args[monitorBranchIdx + 1]);
|
options.monitorBranch = String(args[monitorBranchIdx + 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliBranchIdx = args.indexOf('--cli-branch');
|
|
||||||
if (cliBranchIdx !== -1 && args[cliBranchIdx + 1]) {
|
|
||||||
options.cliBranch = String(args[cliBranchIdx + 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOpenclawPath() {
|
function resolveOpenclawPath() {
|
||||||
if (options.openclawProfilePath) return options.openclawProfilePath;
|
if (options.openclawProfilePath) return options.openclawProfilePath;
|
||||||
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
|
if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH);
|
||||||
@@ -328,35 +321,34 @@ async function installCli() {
|
|||||||
const binDir = join(openclawPath, 'bin');
|
const binDir = join(openclawPath, 'bin');
|
||||||
mkdirSync(binDir, { recursive: true });
|
mkdirSync(binDir, { recursive: true });
|
||||||
|
|
||||||
// Clone CLI repo to /tmp, build there, copy artifact out. Mirrors
|
// Find CLI source — look for HarborForge.Cli relative to project root
|
||||||
// installManagedMonitor so the install never depends on a checked-out
|
const projectRoot = resolve(__dirname, '..');
|
||||||
// sibling repo at a fixed path.
|
const cliDir = join(projectRoot, 'HarborForge.Cli');
|
||||||
const tmpDir = join('/tmp', `harborforge-cli-${Date.now()}`);
|
|
||||||
|
if (!existsSync(cliDir)) {
|
||||||
|
// Try parent directory (monorepo layout)
|
||||||
|
const monoCliDir = resolve(projectRoot, '..', 'HarborForge.Cli');
|
||||||
|
if (!existsSync(monoCliDir)) {
|
||||||
|
logErr(`Cannot find HarborForge.Cli at ${cliDir} or ${monoCliDir}`);
|
||||||
|
logWarn('Skipping CLI installation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveCliDir = existsSync(cliDir)
|
||||||
|
? cliDir
|
||||||
|
: resolve(projectRoot, '..', 'HarborForge.Cli');
|
||||||
|
|
||||||
|
log(` Building hf from ${effectiveCliDir}...`, 'blue');
|
||||||
|
|
||||||
|
try {
|
||||||
const hfBinary = join(binDir, 'hf');
|
const hfBinary = join(binDir, 'hf');
|
||||||
|
exec(`go build -o ${hfBinary} ./cmd/hf`, { cwd: effectiveCliDir, silent: !options.verbose });
|
||||||
try {
|
|
||||||
log(` Cloning ${CLI_REPO_URL} (branch ${options.cliBranch}) → ${tmpDir}...`, 'blue');
|
|
||||||
exec(`git clone --branch ${shellEscape(options.cliBranch)} --depth 1 ${shellEscape(CLI_REPO_URL)} ${shellEscape(tmpDir)}`, { silent: !options.verbose });
|
|
||||||
|
|
||||||
// Stamp the binary with the version string the prod CLI surfaces in
|
|
||||||
// `hf version`. Fall back to a date-only label if rev-parse fails for
|
|
||||||
// any reason (shallow clone shouldn't, but be defensive).
|
|
||||||
let versionLabel = `${new Date().toISOString().slice(0, 10)}+install`;
|
|
||||||
try {
|
|
||||||
const sha = exec(`git rev-parse --short HEAD`, { cwd: tmpDir, silent: true }).trim();
|
|
||||||
if (sha) versionLabel = `${new Date().toISOString().slice(0, 10)}+${options.cliBranch}-${sha}`;
|
|
||||||
} catch { /* keep fallback */ }
|
|
||||||
|
|
||||||
log(` Building hf (version=${versionLabel})...`, 'blue');
|
|
||||||
const ldflags = `-X git.hangman-lab.top/zhi/HarborForge.Cli/internal/commands.Version=${versionLabel}`;
|
|
||||||
exec(`go build -ldflags ${shellEscape(ldflags)} -o ${shellEscape(hfBinary)} ./cmd/hf`, { cwd: tmpDir, silent: !options.verbose });
|
|
||||||
chmodSync(hfBinary, 0o755);
|
chmodSync(hfBinary, 0o755);
|
||||||
logOk(`hf binary → ${hfBinary} (branch hint: ${options.cliBranch})`);
|
logOk(`hf binary → ${hfBinary}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logErr(`Failed to build hf CLI: ${err.message}`);
|
logErr(`Failed to build hf CLI: ${err.message}`);
|
||||||
logWarn('CLI installation failed, plugin still installed');
|
logWarn('CLI installation failed, plugin still installed');
|
||||||
} finally {
|
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user