Files
HomeAgent/internal/plugins/clawhubadapter/manager/main.js
root a965a74d27 clawhubadapter: agent 全量管理 OpenClaw 插件与通道(管理接口补齐)
新工具(agent 可直接调用):
- plugin_info:插件详情(OC/sidecar/SKILL 类型、工具列表、关联通道及状态)
- plugin_reload:重新加载插件(reloadPlugin)
- channel_list:全部注册通道 + 实时运行状态
- channel_send:向通道注入消息(SendToChannel → channel/send → pollQueues)
- channel_start / channel_stop:通道账号启停

manager 新 RPC:
- plugins/channels:registeredChannels 摘要(type/status/accounts)
- channel/start:fire-and-forget startChannels 恢复账号(startAccount 永久挂起语义)
- channel/stop:stopAccount 停止心跳/轮询,支持按 channel 全停或按 accountId 单停

Go 侧 channelSummary 合并 manager 注册信息与 channel_status 实时缓存(缓存优先)。
验证:微信端到端(本地 mock LLM 源)channel_list 返回 wechat running=true connected=true、
channel_send 投递成功;standalone manager 验证 channel/start 心跳恢复与 channel/stop。
2026-08-02 13:32:51 +08:00

1064 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ---- Utility ----
// stdout 仅承载 JSON-RPC插件/manager 的 console 输出一律走 stderr避免污染协议流
const rawLog = console.log.bind(console);
console.log = (...args) => process.stderr.write(args.map(String).join(' ') + '\n');
function writeJSON(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
function sendError(id, code, message) {
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
}
function notify(method, params) {
writeJSON({ jsonrpc: '2.0', method, params });
}
function readJSON(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return null; }
}
// ---- Enhanced OC Plugin Detection ----
function resolvePackageEntry(dir, pkg) {
const candidates = [];
if (pkg) {
if (pkg.main) candidates.push(path.resolve(dir, pkg.main));
if (pkg.exports) {
const exp = pkg.exports;
if (typeof exp === 'string') candidates.push(path.resolve(dir, exp));
if (exp['.']) {
const dot = exp['.'];
if (typeof dot === 'string') candidates.push(path.resolve(dir, dot));
if (dot.require) candidates.push(path.resolve(dir, dot.require));
if (dot.default) candidates.push(path.resolve(dir, dot.default));
}
}
}
if (!pkg || !pkg.main) {
for (const name of ['index.js', 'main.js', 'src/index.js', 'lib/index.js']) {
candidates.push(path.join(dir, name));
}
}
for (const cp of candidates) {
if (fs.existsSync(cp)) return cp;
}
return null;
}
function ensureOCManifest(dir, name) {
const manifestPath = path.join(dir, 'openclaw.plugin.json');
if (fs.existsSync(manifestPath)) return;
const pkg = readJSON(path.join(dir, 'package.json'));
const entry = resolvePackageEntry(dir, pkg);
const relEntry = entry ? path.relative(dir, entry) : 'index.js';
const manifest = {
name: name,
version: (pkg && pkg.version) || '1.0.0',
entry: relEntry,
description: (pkg && pkg.description) || 'OpenClaw plugin (auto-detected)'
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
process.stderr.write(`[manager] created synthetic manifest: ${manifestPath}\n`);
}
function tryDetectOCPackage(pkgDir, pkgName) {
const pkg = readJSON(path.join(pkgDir, 'package.json'));
if (!pkg) return null;
const entryPath = resolvePackageEntry(pkgDir, pkg);
if (!entryPath) return null;
try {
delete require.cache[require.resolve(entryPath)];
const mod = require(entryPath);
const entry = mod.default || mod;
if (entry && typeof entry === 'object' && typeof entry.register === 'function') {
const name = pkgName || (pkg.openclaw ? (pkg.openclaw.name || pkg.name) : pkg.name) || path.basename(pkgDir);
ensureOCManifest(pkgDir, name);
process.stderr.write(`[manager] enhanced detection found OC plugin: ${name} (via require)\n`);
return { dir: pkgDir, name };
}
} catch (e) {
process.stderr.write(`[manager] require detect failed for ${pkgDir}: ${e.message}\n`);
}
return null;
}
// ---- Plugin registry ----
const loadedPlugins = {}; // name -> { entry, tools: [{name, execute, ...}] }
const allTools = []; // flat list of all tools across all plugins
const allProviders = {}; // type -> { name, instance } across all plugins
const registeredChannels = {}; // name -> { pluginName, channelPlugin, output, send, type, runtime, deliverers, accounts, status }
// ---- OpenClaw channelRuntime mock ----
// 真实通道插件依赖 channelRuntime 完成入站转发dispatchReplyWithBufferedBlockDispatcher
// 出站回调dispatcherOptions.deliver插件自带的发送实现、poll 轮询与 call 透传。
function makeChannelRuntime(chName, ch) {
return {
id: chName,
// OC 通用通道轮询输入Go 端经 channel/send 注入的消息放入 ch.pollQueue
// 插件每次 chatPolls/getPolls 取走poll 语义:取走即消费,不重复投递)。
chatPolls: async (opts) => {
const accountId = (opts && opts.accountId) || 'default';
const limit = (opts && opts.limit) || 20;
const q = ch.pollQueues.get(accountId) || [];
const msgs = q.splice(0, limit).map((m) => ({ ...m }));
return { msgs };
},
getPolls: async (opts) => {
const accountId = (opts && opts.accountId) || 'default';
const limit = (opts && opts.limit) || 20;
const q = ch.pollQueues.get(accountId) || [];
const msgs = q.splice(0, limit).map((m) => ({ ...m }));
return { msgs };
},
// 插件经 runtime 直接调用服务器 API无目标服务器转发 Go 端作日志/降级
call: async (method, args) => {
notify('channel_output', { channel: chName, type: 'call', method, args });
return { ok: true };
},
reply: {
// 入站消息 + 出站 deliver 绑定。ctx 来自真实插件Body/From/SessionKey/AccountId...
dispatchReplyWithBufferedBlockDispatcher: async (opts) => {
const ctx = (opts && opts.ctx) || {};
const dopts = (opts && opts.dispatcherOptions) || {};
const accountId = ctx.AccountId || ctx.accountId || 'default';
ch.deliverers.set(accountId, {
deliver: dopts.deliver,
typingCallbacks: dopts.typingCallbacks || {},
});
notify('channel_input', {
channel: chName,
payload: {
content: ctx.BodyForAgent || ctx.Body || ctx.RawBody || '',
from: ctx.From || ctx.SenderId || '',
sessionKey: ctx.SessionKey || '',
accountId,
messageSid: ctx.MessageSid || '',
chatType: ctx.ChatType || 'direct',
raw: ctx,
},
});
const buffer = [];
return {
sendNow: async (items) => {
for (const item of (items || [])) await deliverItem(ch, accountId, item);
},
addToBuffer: async (item) => { buffer.push(item); },
sendBuffer: async () => {
for (const item of buffer) await deliverItem(ch, accountId, item);
buffer.length = 0;
},
closeBuffer: async () => {},
};
},
},
};
}
async function deliverItem(ch, accountId, item) {
const d = ch.deliverers.get(accountId);
if (!d || typeof d.deliver !== 'function') return;
const payload = {
text: (item && item.text) || '',
mediaUrls: (item && (item.mediaUrls || (item.mediaUrl ? [item.mediaUrl] : []))) || [],
};
if (d.typingCallbacks && typeof d.typingCallbacks.onReplyStart === 'function') {
try { await d.typingCallbacks.onReplyStart(payload); } catch {}
}
await d.deliver(payload);
if (d.typingCallbacks && typeof d.typingCallbacks.onCleanup === 'function') {
try { await d.typingCallbacks.onCleanup(payload); } catch {}
}
}
// ---- OpenClaw gateway 生命周期桥 ----
// 通道插件的心跳/收消息/状态上报都挂在 gateway.startAccount 上startAccount 会永久挂起
// await new Promise(()=>{})),配合 OC health-monitor 判定账号存活,必须 fire-and-forget。
async function startChannels(name) {
const ch = registeredChannels[name];
if (!ch || !ch.channelPlugin) return;
const gateway = ch.channelPlugin.gateway;
if (!gateway || typeof gateway.startAccount !== 'function') return;
ch.runtime = ch.runtime || makeChannelRuntime(name, ch);
ch.accounts = ch.accounts || {};
ch.status = ch.status || { running: false, connected: false };
const chCfg = ch.channelPlugin.config || {};
const passCfg = ch.pluginConfig || chCfg;
let accountIds = [];
try {
if (typeof chCfg.listAccountIds === 'function') {
// OC 规范签名: listAccountIds(cfg)
const ids = chCfg.listAccountIds(passCfg);
accountIds = (ids && ids.then ? await ids : (Array.isArray(ids) ? ids : []));
}
} catch (e) {
process.stderr.write(`[manager] ${name}: listAccountIds failed: ${e.message}\n`);
}
if (!Array.isArray(accountIds) || accountIds.length === 0) accountIds = ['default'];
for (const accountId of accountIds) {
let account = null;
try {
if (typeof chCfg.resolveAccount === 'function') {
// OC 规范签名: resolveAccount(cfg, accountId)
account = chCfg.resolveAccount(passCfg, accountId);
if (account && account.then) account = await account;
}
} catch (e) {
process.stderr.write(`[manager] ${name}: resolveAccount(${accountId}) failed: ${e.message}\n`);
}
if (!account) account = { accountId };
if (ch.accounts[accountId]) continue;
ch.accounts[accountId] = { started: true };
const getStatus = () => ({ ...ch.status, accountId });
const setStatus = (patch) => {
ch.status = { ...ch.status, ...(patch || {}) };
notify('channel_status', { channel: name, status: { ...ch.status, accountId } });
};
const ctx = {
account,
cfg: ch.pluginConfig || chCfg,
channelRuntime: ch.runtime,
getStatus,
setStatus,
};
gateway.startAccount(ctx).catch((err) => {
process.stderr.write(`[manager] ${name}: startAccount(${accountId}) crashed: ${err.message}\n`);
ch.status = { ...ch.status, running: false, connected: false, error: err.message };
notify('channel_status', { channel: name, status: { ...ch.status, accountId } });
});
}
}
function stopChannels(name) {
const ch = registeredChannels[name];
if (!ch || !ch.channelPlugin || !ch.accounts) return;
const gateway = ch.channelPlugin.gateway;
if (!gateway || typeof gateway.stopAccount !== 'function') return;
for (const accountId of Object.keys(ch.accounts)) {
const account = { accountId };
gateway.stopAccount({ account, channelRuntime: ch.runtime, cfg: ch.pluginConfig || {} }).catch((err) => {
process.stderr.write(`[manager] ${name}: stopAccount(${accountId}) failed: ${err.message}\n`);
});
}
ch.accounts = {};
}
function registerPluginTools(name, tools, api) {
for (const t of tools) {
if (t && t.name) {
t._plugin = name;
allTools.push(t);
notify('register', { type: 'tool', data: { name: t.name, description: t.description, parameters: t.parameters, plugin: name } });
}
}
loadedPlugins[name] = { tools, api };
}
function loadPlugin(pluginDir, name) {
// Resolve entry
const pkg = readJSON(path.join(pluginDir, 'package.json'));
let entryPath = null;
if (pkg && pkg.openclaw) {
let raw = pkg.openclaw.runtimeExtensions || pkg.openclaw.extensions;
if (typeof raw === 'string') raw = [raw];
if (Array.isArray(raw)) {
for (const ext of raw) {
let ep = path.resolve(pluginDir, ext);
if (ep.endsWith('.ts')) { const js = ep.replace(/\.ts$/, '.js'); if (fs.existsSync(js)) { entryPath = js; break; } }
if (fs.existsSync(ep)) { entryPath = ep; break; }
}
}
}
if (!entryPath) {
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
if (manifest) {
const ep = manifest.entry || manifest.main || 'index.js';
entryPath = path.join(pluginDir, ep);
}
}
if (!entryPath) entryPath = path.join(pluginDir, 'index.js');
if (!fs.existsSync(entryPath)) {
process.stderr.write(`[manager] entry not found for ${name}: ${entryPath}\n`);
return false;
}
let pluginEntry;
try { pluginEntry = require(entryPath); } catch (e) {
process.stderr.write(`[manager] load ${name}: ${e.message}\n`);
return false;
}
const entry = pluginEntry.default || pluginEntry;
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
process.stderr.write(`[manager] ${name}: entry must export {register(api)}\n`);
return false;
}
const registeredTools = [];
const api = {
id: name,
name,
version: (pkg && pkg.version) || '1.0.0',
description: (pkg && pkg.description) || '',
source: pluginDir,
rootDir: pluginDir,
config: {},
pluginConfig: {},
registrationMode: 'full',
logger: { debug: () => {}, info: () => {}, warn: () => {}, error: (...args) => process.stderr.write(`[${name}] ${args.join(' ')}\n`) },
resolvePath: (p) => path.resolve(pluginDir, p),
registerTool: (def, opts) => {
if (typeof def === 'function') {
const toolCtx = { id: name, cwd: pluginDir, env: process.env, allow: ['*'] };
const result = def(toolCtx);
const tools = Array.isArray(result) ? result : [result];
for (const t of tools) { if (t && typeof t.execute === 'function') registeredTools.push(t); }
return;
}
if (!def || !def.name) return;
registeredTools.push({ name: def.name, label: def.label || def.name, description: def.description || '', parameters: def.parameters || { type: 'object', properties: {} }, execute: typeof def.execute === 'function' ? def.execute : undefined });
},
registerProvider: (p) => {
if (p && p.id) allProviders['llm'] = { name: p.id, instance: p };
notify('register', { type: 'provider', data: { name: p?.id || p?.name, plugin: name } });
},
registerChannel: (ch) => {
let chName = ch.name;
let chType = ch.type || 'text';
const chPlugin = ch.plugin;
// OpenClaw ChannelPlugin 格式: { plugin: { id, gateway: {startAccount, stopAccount}, config: {...}, ... } }
if (chPlugin && typeof chPlugin === 'object') {
chName = chName || chPlugin.id || chPlugin.meta?.id || name + '-channel';
chType = chType || (chPlugin.capabilities?.media ? 'io' : 'text');
registeredChannels[chName] = {
pluginName: name, channelPlugin: chPlugin, type: chType,
deliverers: new Map(), accounts: {}, status: {},
pollQueues: new Map(),
};
// gateway 生命周期桥fire-and-forget绝不阻塞 registerChannel
startChannels(chName).catch((e) =>
process.stderr.write(`[manager] ${name}: startChannels failed: ${e.message}\n`));
} else {
// 简单格式: { name, type, output }
registeredChannels[chName] = { pluginName: name, output: ch.output || ch.send, type: chType };
}
notify('register', { type: 'channel', data: { name: chName, type: chType, id: chPlugin?.id, plugin: name } });
},
submitInput: (msg) => {
notify('channel_input', { channel: name, payload: msg });
},
registerHook: (hook) => notify('register', { type: 'hook', data: { name: hook.name, event: hook.event } }),
registerHttpRoute: (route) => notify('register', { type: 'http_route', data: { path: route.path, method: route.method } }),
registerCommand: (cmd) => notify('register', { type: 'command', data: { name: cmd.name, description: cmd.description } }),
registerService: (svc) => notify('register', { type: 'service', data: { name: svc.name } }),
registerImageGenerationProvider: (p) => {
if (p) allProviders['image_generation'] = { name: p.name, instance: p };
notify('register', { type: 'image_generation_provider', data: { name: p?.name, plugin: name } });
},
registerWebFetchProvider: (p) => {
if (p) allProviders['web_fetch'] = { name: p.name, instance: p };
notify('register', { type: 'web_fetch_provider', data: { name: p?.name, plugin: name } });
},
registerWebSearchProvider: (p) => {
if (p) allProviders['web_search'] = { name: p.name, instance: p };
notify('register', { type: 'web_search_provider', data: { name: p?.name, plugin: name } });
},
registerSpeechProvider: (p) => {
if (p) allProviders['speech'] = { name: p.name, instance: p };
notify('register', { type: 'speech_provider', data: { name: p?.name, plugin: name } });
},
registerRealtimeTranscriptionProvider: (p) => {
if (p) allProviders['realtime_transcription'] = { name: p.name, instance: p };
notify('register', { type: 'realtime_transcription_provider', data: { name: p?.name, plugin: name } });
},
registerRealtimeVoiceProvider: (p) => {
if (p) allProviders['realtime_voice'] = { name: p.name, instance: p };
notify('register', { type: 'realtime_voice_provider', data: { name: p?.name, plugin: name } });
},
registerMediaUnderstandingProvider: (p) => {
if (p) allProviders['media_understanding'] = { name: p.name, instance: p };
notify('register', { type: 'media_understanding_provider', data: { name: p?.name, plugin: name } });
},
registerMusicGenerationProvider: (p) => {
if (p) allProviders['music_generation'] = { name: p.name, instance: p };
notify('register', { type: 'music_generation_provider', data: { name: p?.name, plugin: name } });
},
registerVideoGenerationProvider: (p) => {
if (p) allProviders['video_generation'] = { name: p.name, instance: p };
notify('register', { type: 'video_generation_provider', data: { name: p?.name, plugin: name } });
},
registerEmbeddingProvider: (p) => {
if (p) allProviders['embedding'] = { name: p.name, instance: p };
notify('register', { type: 'embedding_provider', data: { name: p?.name, plugin: name } });
},
registerMemoryEmbeddingProvider: (p) => {
if (p) allProviders['memory_embedding'] = { name: p.name, instance: p };
notify('register', { type: 'memory_embedding_provider', data: { name: p?.name, plugin: name } });
},
start: (cb) => {},
stop: (cb) => {},
};
entry.register(api);
registerPluginTools(name, registeredTools, api);
process.stderr.write(`[manager] loaded plugin: ${name} (${registeredTools.length} tools)\n`);
return true;
}
// ---- Install npm package ----
function installNPMPackage(spec, skillsDir) {
// Strip npm: prefix if present
if (spec.startsWith('npm:')) spec = spec.slice(4);
process.stderr.write(`[manager] installing: ${spec}\n`);
const installDir = path.join(skillsDir, '.npm_install_' + Date.now());
fs.mkdirSync(installDir, { recursive: true });
try {
execSync(`npm install ${spec} --no-save --prefix "${installDir}"`, {
cwd: installDir, stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120000, env: { ...process.env, NODE_PATH: path.join(installDir, 'node_modules') }
});
} catch (e) {
fs.rmSync(installDir, { recursive: true, force: true });
return { error: e.stderr ? e.stderr.toString() : e.message };
}
const nm = path.join(installDir, 'node_modules');
if (!fs.existsSync(nm)) {
fs.rmSync(installDir, { recursive: true, force: true });
return { error: 'node_modules not created' };
}
let foundPluginDir = null;
let foundName = null;
const entries = fs.readdirSync(nm);
for (const entry of entries) {
const dir = path.join(nm, entry);
if (!fs.statSync(dir).isDirectory()) continue;
if (entry.startsWith('@')) {
const subs = fs.readdirSync(dir);
for (const sub of subs) {
const subDir = path.join(dir, sub);
if (fs.existsSync(path.join(subDir, 'openclaw.plugin.json')) ||
(fs.existsSync(path.join(subDir, 'package.json')) && readJSON(path.join(subDir, 'package.json'))?.openclaw)) {
foundPluginDir = subDir;
foundName = entry + '/' + sub;
}
}
continue;
}
if (fs.existsSync(path.join(dir, 'openclaw.plugin.json')) ||
(fs.existsSync(path.join(dir, 'package.json')) && readJSON(path.join(dir, 'package.json'))?.openclaw)) {
foundPluginDir = dir;
foundName = entry;
}
}
if (!foundPluginDir) {
process.stderr.write(`[manager] standard scan failed, trying enhanced detection...\n`);
for (const entry of entries) {
if (foundPluginDir) break;
const dir = path.join(nm, entry);
if (!fs.statSync(dir).isDirectory()) continue;
if (entry.startsWith('@')) {
for (const sub of fs.readdirSync(dir)) {
if (foundPluginDir) break;
const subDir = path.join(dir, sub);
const detected = tryDetectOCPackage(subDir, entry + '/' + sub);
if (detected) { foundPluginDir = detected.dir; foundName = detected.name; }
}
} else {
const detected = tryDetectOCPackage(dir, entry);
if (detected) { foundPluginDir = detected.dir; foundName = detected.name; }
}
}
}
if (!foundPluginDir) {
fs.rmSync(installDir, { recursive: true, force: true });
return { error: `no OC plugin found in installed package "${spec}"` };
}
// Copy to skills dir
const targetDir = path.join(skillsDir, foundName);
if (fs.existsSync(targetDir)) fs.rmSync(targetDir, { recursive: true, force: true });
cpSync(foundPluginDir, targetDir);
fs.rmSync(installDir, { recursive: true, force: true });
return { name: foundName, dir: targetDir };
}
function cpSync(src, dst) {
fs.mkdirSync(dst, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dst, entry);
if (fs.statSync(s).isDirectory()) {
cpSync(s, d);
} else {
fs.copyFileSync(s, d);
}
}
}
// ---- OpenClaw CLI compatibility ----
function showQR(text) {
try {
const qrcode = require('qrcode');
qrcode.generate(text, { small: true }, (qr) => process.stdout.write(qr + '\n'));
} catch {
process.stdout.write(`QR: ${text}\n`);
process.stdout.write('(install qrcode package for QR display: npm install qrcode)\n');
}
}
async function runCLI(skillsDir, cliArgs) {
const cmd = cliArgs[0] || '';
switch (cmd) {
case 'plugin:install': {
const spec = cliArgs[1];
if (!spec) throw new Error('Usage: openclaw plugin:install <npm:package|clawhub:name|path>');
const result = installNPMPackage(spec, skillsDir);
if (result.error) throw new Error(result.error);
console.log(`Installed: ${result.name}`);
// Write install result for CLI wrapper to pick up
const simDir = path.dirname(process.argv[1]);
fs.writeFileSync(path.join(simDir, '.install-result'), JSON.stringify({ name: result.name }));
break;
}
case 'plugin:uninstall': {
const name = cliArgs[1];
if (!name) throw new Error('Usage: openclaw plugin:uninstall <name>');
const targetDir = path.join(skillsDir, name);
if (!fs.existsSync(targetDir)) throw new Error(`Plugin not found: ${name}`);
fs.rmSync(targetDir, { recursive: true, force: true });
console.log(`Uninstalled: ${name}`);
break;
}
case 'plugin:list': {
if (!fs.existsSync(skillsDir)) { console.log('(no plugins)'); break; }
let count = 0;
for (const entry of fs.readdirSync(skillsDir)) {
if (entry.startsWith('.')) continue;
const pluginDir = path.join(skillsDir, entry);
if (!fs.statSync(pluginDir).isDirectory()) continue;
const pkg = readJSON(path.join(pluginDir, 'package.json'));
const manifest = readJSON(path.join(pluginDir, 'openclaw.plugin.json'));
if (pkg || manifest) {
const version = pkg?.version || manifest?.version || '?';
const desc = pkg?.description || manifest?.description || '';
console.log(` ${entry} v${version}${desc ? ' — ' + desc : ''}`);
count++;
}
}
if (count === 0) console.log('(no OpenClaw plugins)');
break;
}
case 'auth:login':
case 'auth:qrcode': {
const url = cliArgs[1] || 'openclaw://auth';
console.log('Scan the QR code to log in:');
showQR(url);
console.log('\nOr open this URL:');
console.log(` ${url}`);
break;
}
case 'auth:status': {
console.log('Auth status: not implemented (running in HomeAgent mode)');
break;
}
case 'config:get': {
const key = cliArgs[1];
if (!key) throw new Error('Usage: openclaw config:get <key>');
// TODO: read from HomeAgent config system when bridged
console.log(`(not available in CLI mode: ${key})`);
break;
}
case 'config:set': {
const key = cliArgs[1];
const value = cliArgs[2];
if (!key || value === undefined) throw new Error('Usage: openclaw config:set <key> <value>');
// TODO: write to HomeAgent config system when bridged
console.log(`(not available in CLI mode: ${key}=${value})`);
break;
}
case 'config:list':
console.log('(not available in CLI mode)');
break;
case 'env': {
const info = {
homeAgent: true,
openclawVersion: 'compatible',
platform: process.platform,
nodeVersion: process.version,
skillsDir,
};
console.log(JSON.stringify(info, null, 2));
break;
}
case '--version':
case 'version':
console.log('HomeAgent OpenClaw Adapter 1.0.0 (openclaw-compatible)');
break;
case 'help':
case '--help':
console.log(`Usage: openclaw <command> [args]
Commands:
plugin:install <spec> Install a plugin (npm:xxx, clawhub:xxx, or path)
plugin:uninstall <name> Uninstall a plugin
plugin:list List installed plugins
auth:login [url] Show QR code for login/binding
auth:status Check authentication status
config:get <key> Get config value
config:set <key> <val> Set config value
config:list List all config
env Show runtime environment info
--version Show version
help Show this help`);
break;
default:
throw new Error(`Unknown command: ${cmd}\nRun 'openclaw help' for usage.`);
}
}
// ---- Main ----
const args = process.argv.slice(2);
if (args.length < 1) {
process.stderr.write('[manager] usage: node main.js <skills-dir> [openclaw-command...]\n');
process.exit(1);
}
const skillsDir = path.resolve(args[0]);
// CLI mode: if additional args provided, run as openclaw CLI command and exit
if (args.length > 1) {
const cliArgs = args.slice(1);
runCLI(skillsDir, cliArgs).then(() => process.exit(0)).catch(e => {
process.stderr.write(`Error: ${e.message}\n`);
process.exit(1);
});
return;
}
// Server mode: persist skills dir reference for CLI wrapper
try {
const simDir = path.dirname(process.argv[1]);
fs.writeFileSync(path.join(simDir, '.skillsdir'), skillsDir);
} catch (e) {
process.stderr.write(`[manager] warning: could not write .skillsdir: ${e.message}\n`);
}
// Add our bin directory to PATH so subprocesses can find 'openclaw' CLI
try {
const binDir = path.join(path.dirname(process.argv[1]), 'bin');
if (fs.existsSync(binDir)) {
const PATH = process.env.PATH || '';
if (!PATH.includes(binDir)) {
process.env.PATH = binDir + path.delimiter + PATH;
}
}
} catch (e) {
process.stderr.write(`[manager] warning: could not update PATH: ${e.message}\n`);
}
// Load existing plugins on startup
process.stderr.write(`[manager] scanning: ${skillsDir}\n`);
if (fs.existsSync(skillsDir)) {
for (const entry of fs.readdirSync(skillsDir)) {
if (entry.startsWith('.')) continue;
const pluginDir = path.join(skillsDir, entry);
if (!fs.statSync(pluginDir).isDirectory()) continue;
if (fs.existsSync(path.join(pluginDir, 'main.js')) || fs.existsSync(path.join(pluginDir, 'main.py'))) {
continue;
}
if (fs.existsSync(path.join(pluginDir, 'openclaw.plugin.json')) ||
(fs.existsSync(path.join(pluginDir, 'package.json')) && readJSON(path.join(pluginDir, 'package.json'))?.openclaw)) {
loadPlugin(pluginDir, entry);
} else {
const detected = tryDetectOCPackage(pluginDir, entry);
if (detected) {
loadPlugin(detected.dir, detected.name);
}
}
}
}
// ---- JSON-RPC ----
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false });
rl.on('line', async (line) => {
let req;
try { req = JSON.parse(line); } catch { sendError(null, -32700, 'Parse error'); return; }
const id = req.id;
const method = req.method;
if (method === 'ping') {
writeJSON({ jsonrpc: '2.0', id, result: { status: 'ok', plugins: Object.keys(loadedPlugins).length } });
return;
}
// 向通道注入输入Go 端外部输入webui 会话/其他插件)→ pollQueue → 插件 chatPolls 轮询取走
if (method === 'channel/send') {
const chName = req.params?.channel;
const payload = req.params?.payload || {};
const accountId = (req.params && req.params.accountId) || payload.accountId || 'default';
const ch = registeredChannels[chName];
if (!ch) { sendError(id, -32601, `channel not found: ${chName}`); return; }
ch.pollQueues.set(accountId, ch.pollQueues.get(accountId) || []);
ch.pollQueues.get(accountId).push({
sessionKey: payload.sessionKey || `${chName}:${accountId}:${payload.from || 'poll'}`,
from: payload.from || '',
text: payload.content || payload.text || '',
type: payload.type || 'text',
timestamp: Date.now(),
});
writeJSON({ jsonrpc: '2.0', id, result: { status: 'queued', channel: chName, accountId } });
return;
}
// 停止通道账号stopAccount停止心跳/轮询。channel 可省略(停该插件全部通道)
if (method === 'channel/stop') {
const chName = req.params?.channel;
const accountId = req.params?.accountId;
if (chName) {
const ch = registeredChannels[chName];
if (!ch) { sendError(id, -32601, `channel not found: ${chName}`); return; }
if (accountId) {
const gateway = ch.channelPlugin && ch.channelPlugin.gateway;
if (gateway && typeof gateway.stopAccount === 'function') {
gateway.stopAccount({ account: { accountId }, channelRuntime: ch.runtime, cfg: ch.pluginConfig || {} }).catch((e) =>
process.stderr.write(`[manager] ${chName}: stopAccount(${accountId}) failed: ${e.message}\n`));
}
if (ch.accounts) delete ch.accounts[accountId];
} else {
stopChannels(chName);
}
writeJSON({ jsonrpc: '2.0', id, result: { status: 'stopped', channel: chName, accountId: accountId || 'all' } });
} else {
for (const name of Object.keys(registeredChannels)) stopChannels(name);
writeJSON({ jsonrpc: '2.0', id, result: { status: 'stopped', channel: 'all' } });
}
return;
}
// 启动通道账号gateway.startAccount fire-and-forget
if (method === 'channel/start') {
const chName = req.params?.channel;
const ch = registeredChannels[chName];
if (!ch) { sendError(id, -32601, `channel not found: ${chName}`); return; }
ch.accounts = {};
startChannels(chName).catch((e) => process.stderr.write(`[manager] ${chName}: startChannels failed: ${e.message}\n`));
writeJSON({ jsonrpc: '2.0', id, result: { status: 'started', channel: chName } });
return;
}
if (method === 'plugins/list') {
const list = Object.entries(loadedPlugins).map(([name, p]) => ({
name,
tools: p.tools.map(t => ({ name: t.name, description: t.description })),
}));
writeJSON({ jsonrpc: '2.0', id, result: { plugins: list } });
return;
}
if (method === 'plugins/channels') {
const list = Object.entries(registeredChannels).map(([name, ch]) => ({
name,
plugin: ch.pluginName,
type: ch.type || 'text',
status: ch.status || {},
accounts: ch.accounts ? Object.keys(ch.accounts) : [],
}));
writeJSON({ jsonrpc: '2.0', id, result: { channels: list } });
return;
}
if (method === 'plugins/install') {
const pkg = req.params?.package;
if (!pkg) { sendError(id, -32602, 'package required'); return; }
const result = installNPMPackage(pkg, skillsDir);
if (result.error) {
sendError(id, -32603, result.error);
return;
}
// Load the newly installed plugin
const ok = loadPlugin(result.dir, result.name);
if (!ok) {
sendError(id, -32603, `failed to load installed plugin: ${result.name}`);
return;
}
writeJSON({ jsonrpc: '2.0', id, result: { name: result.name, tools: loadedPlugins[result.name].tools.map(t => t.name) } });
return;
}
if (method === 'plugins/uninstall') {
const name = req.params?.name;
if (!name) { sendError(id, -32602, 'name required'); return; }
if (!loadedPlugins[name]) { sendError(id, -32601, `plugin not found: ${name}`); return; }
// 先优雅停靠通道账号stopAccount 停止心跳/轮询),再卸载
for (const chName of Object.keys(registeredChannels)) {
if (registeredChannels[chName].pluginName === name) {
stopChannels(chName);
}
}
// Remove tools
const idxs = [];
for (let i = allTools.length - 1; i >= 0; i--) {
if (allTools[i]._plugin === name) allTools.splice(i, 1);
}
delete loadedPlugins[name];
// Remove directory
const pluginDir = path.join(skillsDir, name);
if (fs.existsSync(pluginDir)) fs.rmSync(pluginDir, { recursive: true, force: true });
writeJSON({ jsonrpc: '2.0', id, result: { status: 'uninstalled', name } });
return;
}
if (method === 'plugins/detect') {
const pluginDir = req.params?.dir;
const name = req.params?.name || (pluginDir ? path.basename(pluginDir) : '');
if (!pluginDir) { sendError(id, -32602, 'dir required'); return; }
const resolvedDir = path.resolve(pluginDir);
if (!fs.existsSync(resolvedDir)) {
sendError(id, -32601, `directory not found: ${resolvedDir}`);
return;
}
if (loadedPlugins[name]) {
writeJSON({ jsonrpc: '2.0', id, result: { name, tools: loadedPlugins[name].tools.map(t => t.name), type: 'loaded' } });
return;
}
// Install npm dependencies if package.json exists with deps
const pkgPath = path.join(resolvedDir, 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = readJSON(pkgPath);
if (pkg) {
const hasDeps = (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) ||
(pkg.devDependencies && Object.keys(pkg.devDependencies).length > 0);
if (hasDeps) {
try {
execSync(`npm install --no-save --prefix "${resolvedDir}"`, {
cwd: resolvedDir, stdio: ['pipe', 'pipe', 'pipe'],
timeout: 120000, env: { ...process.env, NODE_PATH: path.join(resolvedDir, 'node_modules') }
});
process.stderr.write(`[manager] installed dependencies for ${name}\n`);
} catch (e) {
process.stderr.write(`[manager] npm install failed for ${name}: ${e.message}\n`);
}
}
}
}
let detected = null;
if (fs.existsSync(path.join(resolvedDir, 'openclaw.plugin.json')) ||
(fs.existsSync(path.join(resolvedDir, 'package.json')) && readJSON(path.join(resolvedDir, 'package.json'))?.openclaw)) {
detected = { dir: resolvedDir, name };
} else {
detected = tryDetectOCPackage(resolvedDir, name);
}
if (!detected) {
sendError(id, -32601, `no OC plugin detected in: ${resolvedDir}`);
return;
}
const ok = loadPlugin(detected.dir, detected.name);
if (!ok) {
sendError(id, -32603, `failed to load detected plugin: ${detected.name}`);
return;
}
writeJSON({ jsonrpc: '2.0', id, result: { name: detected.name, tools: loadedPlugins[detected.name].tools.map(t => t.name), type: detected === Object(detected) && detected.dir === resolvedDir ? 'detected' : 'standard' } });
return;
}
if (method === 'plugins/load') {
const dir = req.params?.dir;
const pluginName = req.params?.name || (dir ? path.basename(dir) : '');
if (!dir) { sendError(id, -32602, 'dir required'); return; }
const resolvedDir = path.resolve(dir);
if (!fs.existsSync(resolvedDir)) { sendError(id, -32601, `directory not found: ${resolvedDir}`); return; }
if (loadedPlugins[pluginName]) {
writeJSON({ jsonrpc: '2.0', id, result: { name: pluginName, tools: loadedPlugins[pluginName].tools.map(t => t.name), type: 'already_loaded' } });
return;
}
const ok = loadPlugin(resolvedDir, pluginName);
if (!ok) { sendError(id, -32603, `failed to load plugin: ${pluginName}`); return; }
writeJSON({ jsonrpc: '2.0', id, result: { name: pluginName, tools: loadedPlugins[pluginName].tools.map(t => t.name), type: 'loaded' } });
return;
}
if (method === 'tools/list') {
const tools = allTools.map(t => ({
name: t.name,
description: t.description || '',
inputSchema: t.parameters || { type: 'object', properties: {} },
}));
writeJSON({ jsonrpc: '2.0', id, result: { tools } });
return;
}
if (method === 'tools/call') {
const params = req.params || {};
const toolName = params.name;
const args = params.arguments || {};
// 通道输出路由toolName 匹配已注册通道名时,调通道的输出 handler
const ch = registeredChannels[toolName];
if (ch) {
try {
const channelPlugin = ch.channelPlugin;
let metaObj = {};
try { metaObj = typeof args.meta === 'string' ? JSON.parse(args.meta) : (args.meta || {}); } catch {}
const accountId = metaObj.accountId || 'default';
if (channelPlugin && channelPlugin.outbound) {
// 旧格式 outbound 直发
const to = metaObj.user_id || metaObj.to || metaObj.group_id || '';
const ctx = { to, text: args.payload || '', mediaUrl: metaObj.mediaUrl || '', cfg: {}, accountId };
let result;
if (ctx.mediaUrl && channelPlugin.outbound.sendMedia) {
result = await channelPlugin.outbound.sendMedia(ctx);
} else if (channelPlugin.outbound.sendText) {
result = await channelPlugin.outbound.sendText(ctx);
} else {
throw new Error(`channel ${toolName} has no sendText/sendMedia handler`);
}
writeJSON({ jsonrpc: '2.0', id, result: { status: 'sent', result } });
} else if (typeof ch.output === 'function') {
const result = await ch.output(args.payload, args.type, args.meta);
writeJSON({ jsonrpc: '2.0', id, result: { status: 'sent', result } });
} else if (typeof ch.send === 'function') {
const result = await ch.send(args.payload, args.meta);
writeJSON({ jsonrpc: '2.0', id, result: { status: 'sent', result } });
} else if (ch.deliverers && ch.deliverers.has(accountId)) {
// OpenClaw ChannelPlugin 事件式发送:回复交给插件在入站时挂载的 deliver插件自带发送实现
await deliverItem(ch, accountId, {
text: args.payload || '',
mediaUrls: metaObj.mediaUrl ? [metaObj.mediaUrl] : [],
});
writeJSON({ jsonrpc: '2.0', id, result: { status: 'sent', via: 'channelRuntime.deliver' } });
} else if (ch.runtime) {
// 通道已启动但尚未收到入站消息deliver 未建立):降级为 channel_output 事件
notify('channel_output', {
channel: toolName, type: 'message',
text: args.payload || '', mediaUrl: metaObj.mediaUrl || '', to: metaObj.user_id || '',
});
writeJSON({ jsonrpc: '2.0', id, result: { status: 'queued', via: 'channel_output' } });
} else {
sendError(id, -32601, `channel ${toolName} has no output handler`);
}
} catch (e) { sendError(id, -32603, e.message); }
return;
}
const tool = allTools.find(t => t.name === toolName);
if (!tool) { sendError(id, -32601, `Tool not found: ${toolName}`); return; }
if (typeof tool.execute !== 'function') { sendError(id, -32603, `Tool ${toolName} has no execute`); return; }
try {
const result = await tool.execute('mgr-call-1', args, undefined, undefined);
if (result && typeof result === 'object' && Array.isArray(result.content)) {
writeJSON({ jsonrpc: '2.0', id, result });
} else {
const text = typeof result === 'string' ? result : JSON.stringify(result);
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
}
} catch (e) { sendError(id, -32603, e.message); }
return;
}
if (method === 'provider/call') {
const { type, action, args } = req.params || {};
if (!type) { sendError(id, -32602, 'type required'); return; }
const provider = allProviders[type];
if (!provider) { sendError(id, -32601, `Provider not found: ${type}`); return; }
const methodName = action || 'execute';
if (typeof provider.instance[methodName] !== 'function') {
sendError(id, -32603, `Provider ${type} has no method ${methodName}`);
return;
}
try {
const result = await provider.instance[methodName](args);
if (result && typeof result === 'object' && Array.isArray(result.content)) {
writeJSON({ jsonrpc: '2.0', id, result });
} else {
const text = typeof result === 'string' ? result : JSON.stringify(result);
writeJSON({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text }] } });
}
} catch (e) { sendError(id, -32603, e.message); }
return;
}
sendError(id, -32601, `Method not found: ${method}`);
});
// ---- 优雅停靠:进程退出前逐个 stopAccount插件停止心跳/轮询) ----
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
for (const name of Object.keys(registeredChannels)) stopChannels(name);
setTimeout(() => process.exit(0), 2000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);