refactor: rename openclaw -> clawhubadapter, add ClawHub search/install, fix agent interrupt

- Rename internal/plugins/openclaw/ -> internal/plugins/clawhubadapter/
- Add RegistryDispatcher with 5 sub-registries (Tool, Provider, Channel, Stage, Cap)
- Add clawhubadapter_search tool for ClawHub marketplace search
- Add clawhub: prefix support for installing from ClawHub (ZIP/tgz auto-detect)
- Add CallProvider RPC and provider/call routing
- Fix QQ interrupt: check interceptCh before/after each tool execution
This commit is contained in:
2026-07-21 22:47:14 +08:00
parent 906d117bdd
commit 76a4619ed5
15 changed files with 758 additions and 186 deletions

View File

@ -0,0 +1,361 @@
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// ---- Utility ----
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; }
}
// ---- 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
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 } });
}
}
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 } });
},
registerChannel: (ch) => notify('register', { type: 'channel', data: { name: ch.name, type: ch.type } }),
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 } });
},
registerWebFetchProvider: (p) => {
if (p) allProviders['web_fetch'] = { name: p.name, instance: p };
notify('register', { type: 'web_fetch_provider', data: { name: p?.name } });
},
registerWebSearchProvider: (p) => {
if (p) allProviders['web_search'] = { name: p.name, instance: p };
notify('register', { type: 'web_search_provider', data: { name: p?.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) {
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) {
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);
}
}
}
// ---- Main ----
const args = process.argv.slice(2);
if (args.length < 1) {
process.stderr.write('[manager] usage: node main.js <skills-dir>\n');
process.exit(1);
}
const skillsDir = path.resolve(args[0]);
// 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; // skip hidden
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'))) {
process.stderr.write(`[manager] skip non-OC plugin: ${entry} (main.js/main.py)\n`);
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);
}
}
}
// ---- 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;
}
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/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; }
// 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 === '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 || {};
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}`);
});

View File

@ -0,0 +1,868 @@
package clawhubadapter
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
_ "embed"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
//go:embed simulator/main.js
var simulatorSrc string
//go:embed manager/main.js
var managerSrc string
//go:embed pysimulator/main.py
var pySimulatorSrc string
var SkillsDir string
var SimulatorDir string
func init() {
plugin.RegisterPluginMeta("clawhubadapter", "ClawHub 适配器", "ClawHub Adapter")
plugin.RegisterFactory("clawhubadapter", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
dir := SkillsDir
if dir == "" {
dataDir, ok := config["data_dir"].(string)
if !ok {
return nil, fmt.Errorf("clawhubadapter plugin: config missing 'data_dir' or not a string")
}
dir = filepath.Join(dataDir, "skills")
}
return New(name, dir), nil
})
}
type Plugin struct {
name string
skillsDir string
simulatorDir string
skills []*plugin.SKILLPlugin
sidecars []*sidecarProcess
manager *sidecarProcess
mu sync.Mutex
sdk *sdk.PluginSDK
dispatcher *RegistryDispatcher
httpClient *http.Client
}
func New(name, skillsDir string) *Plugin {
sd := SimulatorDir
if sd == "" {
sd = filepath.Join(skillsDir, ".simulator")
}
return &Plugin{
name: name,
skillsDir: skillsDir,
simulatorDir: sd,
dispatcher: NewDispatcher(),
}
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "skills_dir", Type: "string", DisplayName: "Skill 加载目录",
Description: "OpenClaw 技能加载目录路径(留空则使用默认路径)",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "simulator_dir", Type: "string", DisplayName: "模拟器工作目录",
Description: "OpenClaw 模拟器工作目录路径(留空则使用默认路径)",
})
p.httpClient = &http.Client{}
if v, _ := s.Settings().Get("skills_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.skillsDir = s
}
}
if v, _ := s.Settings().Get("simulator_dir"); v != nil {
if s, ok := v.(string); ok && s != "" {
p.simulatorDir = s
}
}
// Launch OC plugin manager first (handles OC-format plugin installation and lifecycle)
os.MkdirAll(p.skillsDir, 0755)
if err := p.launchManager(s); err != nil {
log.Printf("[clawhubadapter] launch manager: %v", err)
}
// Load existing plugins from skills dir
if entries, err := os.ReadDir(p.skillsDir); err == nil {
for _, entry := range entries {
skillPath := filepath.Join(p.skillsDir, entry.Name())
subs, err := os.ReadDir(skillPath)
if err != nil {
continue
}
hasMainJS := false
hasMainPy := false
hasOCManifest := false
hasOCPackage := false
for _, f := range subs {
switch f.Name() {
case "main.js":
hasMainJS = true
case "main.py":
hasMainPy = true
case "openclaw.plugin.json":
hasOCManifest = true
case "package.json":
hasOCPackage = hasOCExtensions(filepath.Join(skillPath, "package.json"))
}
}
switch {
case hasMainJS:
if err := p.loadSidecar(s, skillPath, entry.Name()); err != nil {
log.Printf("[clawhubadapter] sidecar %s: %v", entry.Name(), err)
}
case hasMainPy:
if err := p.loadPySidecar(s, skillPath, entry.Name()); err != nil {
log.Printf("[clawhubadapter] pysidecar %s: %v", entry.Name(), err)
}
case hasOCManifest || hasOCPackage:
log.Printf("[clawhubadapter] ocplugin %s handled by manager", entry.Name())
default:
sk, err := plugin.LoadSKILL(skillPath)
if err != nil {
log.Printf("[clawhubadapter] load skill %s: %v", entry.Name(), err)
continue
}
p.skills = append(p.skills, sk)
log.Printf("[clawhubadapter] loaded skill: %s v%s", sk.Name(), sk.Version())
}
}
}
// Register plugin management tools that talk to the manager (always, even if skills dir is empty)
tp := p.name + "_"
s.RegisterTool(tp+"npm_install", sdk.ToolDef{
Name: tp + "npm_install",
Description: "安装 ClawHub 适配器插件管理器中的插件。支持 npm: 前缀(npm 包)和 clawhub: 前缀(ClawHub 市场)。安装后立即可用。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"package": map[string]interface{}{"type": "string", "description": "插件包标识。npm:<pkg> 从 npm 安装,clawhub:<pkg> 从 ClawHub 市场安装(如 clawhub:openclaw-codex-app-server)"},
},
"required": []string{"package"},
},
}, p.handlePluginInstall)
s.RegisterTool(tp+"npm_uninstall", sdk.ToolDef{
Name: tp + "npm_uninstall",
Description: "从插件管理器中移除已安装的插件。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "要移除的插件名称"},
},
"required": []string{"name"},
},
}, p.handlePluginUninstall)
s.RegisterTool(tp+"search", sdk.ToolDef{
Name: tp + "search",
Description: "搜索 ClawHub 插件市场,查找可安装的插件。返回插件名称、描述和安装命令提示。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{"type": "string", "description": "搜索关键词"},
},
"required": []string{"query"},
},
}, p.handleClawHubSearch)
s.RegisterTool(tp+"list", sdk.ToolDef{
Name: tp + "list",
Description: "列出所有已安装的 ClawHub 插件及其工具。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handlePluginList)
return nil
}
// ─── Manager ────────────────────────────────────────────────
// launchManager 启动 OC 插件管理器(Node.js 进程),用于安装/卸载/加载 OC 格式插件
func (p *Plugin) launchManager(s *sdk.PluginSDK) error {
managerPath := filepath.Join(p.simulatorDir, "manager.js")
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
return fmt.Errorf("create simulator dir: %w", err)
}
if err := os.WriteFile(managerPath, []byte(managerSrc), 0644); err != nil {
return fmt.Errorf("write manager: %w", err)
}
// Ensure skills dir exists for the manager to scan
os.MkdirAll(p.skillsDir, 0755)
sp, err := launchProcess("node", managerPath, p.skillsDir, "manager")
if err != nil {
return fmt.Errorf("launch manager: %w", err)
}
if sp == nil {
return nil
}
// Drain initial registration notifications from already-loaded plugins
for done := false; !done; {
select {
case n := <-sp.NotifyChan():
p.translateAndRegister(n, sp, s, "manager")
default:
done = true
}
}
go p.notifyLoop(sp, s, "manager")
p.mu.Lock()
p.manager = sp
p.sidecars = append(p.sidecars, sp)
p.mu.Unlock()
log.Printf("[clawhubadapter] OC plugin manager started")
return nil
}
// ─── 管理工具处理 ────────────────────────────────────────────
func (p *Plugin) handlePluginInstall(args map[string]interface{}) (interface{}, error) {
pkg, _ := args["package"].(string)
if pkg == "" {
return errorResult("package is required"), nil
}
if strings.HasPrefix(pkg, "clawhub:") {
return p.installFromClawHub(pkg)
}
p.mu.Lock()
mgr := p.manager
p.mu.Unlock()
if mgr == nil {
return errorResult("plugin manager not available"), nil
}
data, err := mgr.call("plugins/install", map[string]interface{}{
"package": pkg,
})
if err != nil {
return errorResult(fmt.Sprintf("install failed: %v", err)), nil
}
var result struct {
Name string `json:"name"`
Tools []string `json:"tools"`
}
if err := json.Unmarshal(data, &result); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("Plugin installed. Raw response: %s", string(data)),
}, nil
}
return map[string]interface{}{
"content": fmt.Sprintf("已安装插件: %s\n 工具: %s", result.Name, strings.Join(result.Tools, ", ")),
}, nil
}
const clawhubAPI = "https://clawhub.ai/api/v1"
func (p *Plugin) installFromClawHub(spec string) (interface{}, error) {
name := strings.TrimPrefix(spec, "clawhub:")
if name == "" {
return errorResult("clawhub package name is required"), nil
}
pkgURL := fmt.Sprintf("%s/packages/%s/download", clawhubAPI, url.PathEscape(name))
resp, err := p.httpClient.Get(pkgURL)
if err != nil {
return errorResult(fmt.Sprintf("download failed: %v", err)), nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return errorResult(fmt.Sprintf("ClawHub API error (status %d): %s", resp.StatusCode, string(body))), nil
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return errorResult(fmt.Sprintf("read download: %v", err)), nil
}
extractDir := filepath.Join(p.skillsDir, name)
os.MkdirAll(extractDir, 0755)
if err := extractArchive(data, extractDir); err != nil {
return errorResult(fmt.Sprintf("extract failed: %v", err)), nil
}
if err := p.reloadPlugin(name); err != nil {
return errorResult(fmt.Sprintf("loaded but with warning: %v", err)), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("已从 ClawHub 安装插件: %s", name),
}, nil
}
func (p *Plugin) handleClawHubSearch(args map[string]interface{}) (interface{}, error) {
query, _ := args["query"].(string)
if query == "" {
return errorResult("query is required"), nil
}
searchURL := fmt.Sprintf("%s/search?q=%s", clawhubAPI, url.QueryEscape(query))
resp, err := p.httpClient.Get(searchURL)
if err != nil {
return errorResult(fmt.Sprintf("search failed: %v", err)), nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result struct {
Results []struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
Version string `json:"version"`
Downloads int `json:"downloads"`
} `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return map[string]interface{}{
"content": fmt.Sprintf("Search results (raw):\n%s", string(body)),
}, nil
}
if len(result.Results) == 0 {
return map[string]interface{}{
"content": fmt.Sprintf("未找到匹配 \"%s\" 的 ClawHub 插件", query),
}, nil
}
var lines []string
for _, pkg := range result.Results {
ver := pkg.Version
if ver == "" {
ver = "latest"
}
name := pkg.DisplayName
if name == "" {
name = pkg.Slug
}
lines = append(lines, fmt.Sprintf("- %s (%s) v%s | ⬇ %d\n %s\n 安装: clawhubadapter_npm_install package=clawhub:%s",
name, pkg.Slug, ver, pkg.Downloads, pkg.Summary, pkg.Slug))
}
return map[string]interface{}{
"content": fmt.Sprintf("在 ClawHub 找到 %d 个插件:\n%s", len(result.Results), strings.Join(lines, "\n")),
}, nil
}
func (p *Plugin) handlePluginUninstall(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
if name == "" {
return errorResult("name is required"), nil
}
p.mu.Lock()
mgr := p.manager
p.mu.Unlock()
if mgr == nil {
return errorResult("plugin manager not available"), nil
}
data, err := mgr.call("plugins/uninstall", map[string]interface{}{
"name": name,
})
if err != nil {
return errorResult(fmt.Sprintf("uninstall failed: %v", err)), nil
}
return map[string]interface{}{
"content": fmt.Sprintf("已卸载插件: %s\n %s", name, string(data)),
}, nil
}
func (p *Plugin) handlePluginList(args map[string]interface{}) (interface{}, error) {
p.mu.Lock()
mgr := p.manager
p.mu.Unlock()
if mgr != nil {
data, err := mgr.call("plugins/list", nil)
if err == nil && data != nil {
var result struct {
Plugins []struct {
Name string `json:"name"`
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
} `json:"tools"`
} `json:"plugins"`
}
if err := json.Unmarshal(data, &result); err == nil {
var parts []string
parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir))
if len(result.Plugins) > 0 {
parts = append(parts, fmt.Sprintf("\nOC 插件 (%d):", len(result.Plugins)))
for _, pl := range result.Plugins {
var names []string
for _, t := range pl.Tools {
names = append(names, t.Name)
}
parts = append(parts, fmt.Sprintf(" %s: %s", pl.Name, strings.Join(names, ", ")))
}
}
p.mu.Lock()
if len(p.sidecars) > 0 {
sidecarCount := 0
for _, sp := range p.sidecars {
if sp != p.manager {
sidecarCount++
}
}
if sidecarCount > 0 {
parts = append(parts, fmt.Sprintf("\nSidecar 插件 (%d):", sidecarCount))
for _, sp := range p.sidecars {
if sp == p.manager {
continue
}
tools, _ := sp.ListTools()
var names []string
for _, t := range tools {
names = append(names, t.Name)
}
parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, strings.Join(names, ", ")))
}
}
}
if len(p.skills) > 0 {
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
for _, sk := range p.skills {
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
}
}
caps := p.dispatcher.Capabilities()
if len(caps) > 0 {
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
for _, c := range caps {
parts = append(parts, fmt.Sprintf(" %s", c))
}
}
if len(result.Plugins) == 0 && len(p.sidecars) <= 1 && len(p.skills) == 0 {
parts = append(parts, "没有已安装的插件。")
}
p.mu.Unlock()
return map[string]interface{}{
"content": strings.Join(parts, "\n"),
}, nil
}
}
}
// Fallback: list known plugins from Go side
p.mu.Lock()
defer p.mu.Unlock()
var parts []string
parts = append(parts, fmt.Sprintf("Skills dir: %s\n", p.skillsDir))
if len(p.sidecars) > 0 {
parts = append(parts, fmt.Sprintf("\nSidecar/OC 插件 (%d):", len(p.sidecars)))
for _, sp := range p.sidecars {
tools, err := sp.ListTools()
toolList := ""
if err == nil {
var names []string
for _, t := range tools {
names = append(names, t.Name)
}
toolList = strings.Join(names, ", ")
}
parts = append(parts, fmt.Sprintf(" %s: %s", sp.name, toolList))
}
}
if len(p.skills) > 0 {
parts = append(parts, fmt.Sprintf("\nSKILL 插件 (%d):", len(p.skills)))
for _, sk := range p.skills {
parts = append(parts, fmt.Sprintf(" %s v%s", sk.Name(), sk.Version()))
}
}
caps := p.dispatcher.Capabilities()
if len(caps) > 0 {
parts = append(parts, fmt.Sprintf("\nCapabilities (%d):", len(caps)))
for _, c := range caps {
parts = append(parts, fmt.Sprintf(" %s", c))
}
}
if len(p.sidecars) == 0 && len(p.skills) == 0 {
parts = append(parts, "没有已安装的插件。")
}
return map[string]interface{}{
"content": strings.Join(parts, "\n"),
}, nil
}
func (p *Plugin) loadOCPlugin(s *sdk.PluginSDK, dir, name string) error {
simPath := filepath.Join(p.simulatorDir, "main.js")
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
return fmt.Errorf("create simulator dir: %w", err)
}
if err := os.WriteFile(simPath, []byte(simulatorSrc), 0644); err != nil {
return fmt.Errorf("write simulator: %w", err)
}
sp, err := launchProcess("node", simPath, dir, name)
if err != nil {
return fmt.Errorf("launch simulator: %w", err)
}
if sp == nil {
return nil
}
// 通知是唯一注册路径。
// 插件 register(api) 期间模拟器将所有 register* 调用以通知推送给 Go。
// waitReady 之后所有初始化通知已缓冲在 notifyCh 中,同步排空处理。
for done := false; !done; {
select {
case n := <-sp.NotifyChan():
p.translateAndRegister(n, sp, s, name)
default:
done = true
}
}
// 启动持久通知协程:后续模拟器推送的注册通知持续转译注册到核心
go p.notifyLoop(sp, s, name)
// ListTools 仅验证日志,不参与注册
tools, err := sp.ListTools()
if err != nil {
sp.Close()
return fmt.Errorf("list tools: %w", err)
}
log.Printf("[clawhubadapter] ocplugin %s verified %d tools via ListTools", name, len(tools))
p.mu.Lock()
p.sidecars = append(p.sidecars, sp)
p.mu.Unlock()
return nil
}
// notifyLoop 持续监听模拟器的注册通知,即时转译注册到核心。
// 生命周期绑定 sidecarProcess.NotifyChan,sp.Close() 时会关闭通道使循环退出。
func (p *Plugin) notifyLoop(sp *sidecarProcess, s *sdk.PluginSDK, pluginName string) {
for n := range sp.NotifyChan() {
p.translateAndRegister(n, sp, s, pluginName)
}
}
func (p *Plugin) translateAndRegister(n OCNotification, sp *sidecarProcess, s *sdk.PluginSDK, pluginName string) {
if n.Method != "register" {
return
}
var params struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(n.Params, &params); err != nil {
return
}
p.dispatcher.Dispatch(params.Type, params.Data, pluginName, sp, s)
}
func (p *Plugin) loadPySidecar(s *sdk.PluginSDK, dir, name string) error {
simPath := filepath.Join(p.simulatorDir, "pysim.py")
if err := os.MkdirAll(p.simulatorDir, 0755); err != nil {
return fmt.Errorf("create simulator dir: %w", err)
}
if err := os.WriteFile(simPath, []byte(pySimulatorSrc), 0644); err != nil {
return fmt.Errorf("write pysimulator: %w", err)
}
// 查找可用的 Python 解释器
pythonBin := "python3"
for _, candidate := range []string{"/usr/bin/python3", "/usr/local/bin/python3"} {
if _, err := os.Stat(candidate); err == nil {
pythonBin = candidate
break
}
}
sp, err := launchProcess(pythonBin, simPath, dir, name)
if err != nil {
return fmt.Errorf("launch pysimulator: %w", err)
}
if sp == nil {
return nil
}
// 处理注册通知 (同 OC 插件流程)
for done := false; !done; {
select {
case n := <-sp.NotifyChan():
p.translateAndRegister(n, sp, s, name)
default:
done = true
}
}
go p.notifyLoop(sp, s, name)
tools, err := sp.ListTools()
if err != nil {
sp.Close()
return fmt.Errorf("list tools: %w", err)
}
log.Printf("[clawhubadapter] pysidecar %s registered %d tools", name, len(tools))
p.mu.Lock()
p.sidecars = append(p.sidecars, sp)
p.mu.Unlock()
return nil
}
func (p *Plugin) loadSidecar(s *sdk.PluginSDK, dir, name string) error {
sp, err := launchSidecar(dir, name)
if err != nil {
return fmt.Errorf("launch: %w", err)
}
if sp == nil {
return nil
}
tools, err := sp.ListTools()
if err != nil {
sp.Close()
return fmt.Errorf("list tools: %w", err)
}
for _, tool := range tools {
toolName := fmt.Sprintf("%s_%s", name, tool.Name)
tDef := sdk.ToolDef{
Name: toolName,
Description: fmt.Sprintf("[%s] %s", name, tool.Description),
Parameters: tool.InputSchema,
}
handler := func(sp *sidecarProcess, toolName string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
return sp.CallTool(toolName, args)
}
}(sp, tool.Name)
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
log.Printf("[clawhubadapter] register sidecar tool %s: %v", toolName, err)
continue
}
log.Printf("[clawhubadapter] registered sidecar tool: %s (from %s)", toolName, name)
}
p.mu.Lock()
p.sidecars = append(p.sidecars, sp)
p.mu.Unlock()
log.Printf("[clawhubadapter] sidecar %s started with %d tools", name, len(tools))
return nil
}
func (p *Plugin) Stop() error {
p.mu.Lock()
defer p.mu.Unlock()
for _, sp := range p.sidecars {
sp.Close()
}
p.sidecars = nil
p.skills = nil
p.manager = nil
p.sdk = nil
return nil
}
func errorResult(msg string) interface{} {
return map[string]interface{}{
"isError": true,
"content": msg,
}
}
// hasOCExtensions 检测 package.json 中是否有 openclaw.extensions 或 openclaw.runtimeExtensions
func hasOCExtensions(pkgPath string) bool {
data, err := os.ReadFile(pkgPath)
if err != nil {
return false
}
var pkg struct {
OpenClaw *struct {
Extensions interface{} `json:"extensions"`
RuntimeExtensions interface{} `json:"runtimeExtensions"`
} `json:"openclaw"`
}
if err := json.Unmarshal(data, &pkg); err != nil {
return false
}
return pkg.OpenClaw != nil && (pkg.OpenClaw.Extensions != nil || pkg.OpenClaw.RuntimeExtensions != nil)
}
func (p *Plugin) reloadPlugin(name string) error {
pluginDir := filepath.Join(p.skillsDir, name)
info, err := os.Stat(pluginDir)
if err != nil || !info.IsDir() {
return fmt.Errorf("plugin dir not found: %s", pluginDir)
}
if p.sdk == nil {
return fmt.Errorf("sdk not initialized")
}
subs, err := os.ReadDir(pluginDir)
if err != nil {
return err
}
hasMainJS := false
hasMainPy := false
hasOCManifest := false
for _, f := range subs {
switch f.Name() {
case "main.js":
hasMainJS = true
case "main.py":
hasMainPy = true
case "openclaw.plugin.json":
hasOCManifest = true
}
}
os.MkdirAll(p.simulatorDir, 0755)
switch {
case hasMainJS:
return p.loadSidecar(p.sdk, pluginDir, name)
case hasMainPy:
return p.loadPySidecar(p.sdk, pluginDir, name)
case hasOCManifest:
return p.loadOCPlugin(p.sdk, pluginDir, name)
default:
sk, err := plugin.LoadSKILL(pluginDir)
if err != nil {
return fmt.Errorf("load skill: %w", err)
}
p.mu.Lock()
p.skills = append(p.skills, sk)
p.mu.Unlock()
return nil
}
}
func extractArchive(data []byte, dest string) error {
if len(data) < 4 {
return fmt.Errorf("archive too small (%d bytes)", len(data))
}
if data[0] == 0x50 && data[1] == 0x4B && data[2] == 0x03 && data[3] == 0x04 {
return extractZIP(data, dest)
}
return extractTGZ(data, dest)
}
func extractZIP(data []byte, dest string) error {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return fmt.Errorf("zip: %w", err)
}
for _, f := range zr.File {
target := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
os.MkdirAll(target, 0755)
continue
}
os.MkdirAll(filepath.Dir(target), 0755)
r, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(target)
if err != nil {
r.Close()
return err
}
_, err = io.Copy(out, r)
r.Close()
out.Close()
if err != nil {
return err
}
}
return nil
}
func extractTGZ(data []byte, dest string) error {
gzr, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return fmt.Errorf("gzip: %w", err)
}
defer gzr.Close()
tr := tar.NewReader(gzr)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Join(dest, header.Name)
switch header.Typeflag {
case tar.TypeDir:
os.MkdirAll(target, 0755)
case tar.TypeReg:
os.MkdirAll(filepath.Dir(target), 0755)
f, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
return err
}
f.Close()
}
}
return nil
}

View File

@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
OpenClaw Python Sidecar Simulator
Loads a Python plugin (main.py) from the plugin directory and
provides JSON-RPC over stdin/stdout for tool listing and execution.
Protocol is identical to the Node.js simulator:
- ping: returns {"status": "ok"}
- tools/list: lists registered tools
- tools/call: executes a tool
Plugin main.py should define:
def register(api):
api.register_tool(name, description, parameters, execute_fn)
"""
import importlib.util
import json
import os
import sys
def resolve_entry(plugin_dir):
"""Resolve the plugin entry point."""
main_py = os.path.join(plugin_dir, 'main.py')
if os.path.isfile(main_py):
return main_py
return None
class PluginAPI:
"""API passed to the plugin's register() function."""
def __init__(self, plugin_dir):
self.id = os.path.basename(plugin_dir)
self.name = os.path.basename(plugin_dir)
self.version = '1.0.0'
self.description = ''
self.source = plugin_dir
self.root_dir = plugin_dir
self.config = {}
self.plugin_config = {}
self.registration_mode = 'full'
self._tools = {}
def register_tool(self, name, description='', parameters=None, execute=None):
if parameters is None:
parameters = {'type': 'object', 'properties': {}}
# Notify Go side
self._notify('register', {
'type': 'tool',
'data': {
'name': name,
'description': description,
'parameters': parameters,
}
})
self._tools[name] = {
'name': name,
'description': description,
'parameters': parameters,
'execute': execute,
}
def register_provider(self, provider_type, provider):
self._notify('register', {
'type': 'provider',
'data': {'name': provider.name if hasattr(provider, 'name') else str(provider)},
})
def register_channel(self, channel):
self._notify('register', {
'type': 'channel',
'data': {'name': channel.name if hasattr(channel, 'name') else str(channel)},
})
def _notify(self, method, params):
line = json.dumps({'jsonrpc': '2.0', 'method': method, 'params': params}, ensure_ascii=False)
sys.stdout.write(line + '\n')
sys.stdout.flush()
def load_plugin(plugin_dir):
"""Load the plugin from main.py and call its register()."""
entry_path = resolve_entry(plugin_dir)
if entry_path is None:
sys.stderr.write(f'[pysimulator] entry not found in {plugin_dir}\n')
sys.exit(1)
sys.path.insert(0, plugin_dir)
spec = importlib.util.spec_from_file_location('plugin_main', entry_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
if not hasattr(module, 'register'):
sys.stderr.write(f'[pysimulator] {entry_path} must define a register(api) function\n')
sys.exit(1)
api = PluginAPI(plugin_dir)
module.register(api)
return api
def main():
if len(sys.argv) < 2:
sys.stderr.write('[pysimulator] usage: python3 main.py <plugin-dir>\n')
sys.exit(1)
plugin_dir = os.path.abspath(sys.argv[1])
api = load_plugin(plugin_dir)
# JSON-RPC loop over stdin
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
resp = {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
sys.stdout.flush()
continue
req_id = req.get('id')
method = req.get('method', '')
if method == 'ping':
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'status': 'ok'}}
elif method == 'tools/list':
tools = [
{'name': t['name'], 'description': t['description'], 'inputSchema': t['parameters']}
for t in api._tools.values()
]
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'tools': tools}}
elif method == 'tools/call':
params = req.get('params', {})
tool_name = params.get('name', '')
arguments = params.get('arguments', {})
tool = api._tools.get(tool_name)
if tool is None:
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32601, 'message': f'Tool not found: {tool_name}'}}
elif tool.get('execute') is None:
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32603, 'message': f'Tool {tool_name} has no execute function'}}
else:
try:
result = tool['execute'](arguments)
if isinstance(result, dict) and 'content' in result:
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': result}
else:
text = str(result) if not isinstance(result, str) else result
resp = {'jsonrpc': '2.0', 'id': req_id, 'result': {'content': [{'type': 'text', 'text': text}]}}
except Exception as e:
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32603, 'message': str(e)}}
else:
resp = {'jsonrpc': '2.0', 'id': req_id, 'error': {'code': -32601, 'message': f'Method not found: {method}'}}
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + '\n')
sys.stdout.flush()
if __name__ == '__main__':
main()

View File

@ -0,0 +1,251 @@
package clawhubadapter
import (
"encoding/json"
"fmt"
"log"
"sync"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
type ToolRegistry struct{}
func (r *ToolRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
var d struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
if err := json.Unmarshal(data, &d); err != nil || d.Name == "" {
return
}
toolName := fmt.Sprintf("%s_%s", pluginName, d.Name)
tDef := sdk.ToolDef{
Name: toolName,
Description: d.Description,
Parameters: d.Parameters,
}
handler := func(sp *sidecarProcess, ocToolName string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
return sp.CallTool(ocToolName, args)
}
}(sp, d.Name)
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
log.Printf("[clawhubadapter] register tool %s: %v", toolName, err)
}
}
type providerDef struct {
ocType string
toolSuffix string
desc string
}
var providerMap = []providerDef{
{"image_generation", "generate_image", "根据文本描述生成图片,返回图片 URL"},
{"music_generation", "generate_music", "根据描述生成音乐"},
{"video_generation", "generate_video", "根据描述生成视频"},
{"speech", "synthesize_speech", "将文本合成为语音"},
{"web_search", "web_search", "搜索互联网信息"},
{"web_fetch", "web_fetch", "获取指定网页的内容"},
{"media_understanding", "analyze_media", "分析图片、音频或视频内容"},
{"realtime_transcription", "transcribe_audio", "将音频转写为文字"},
{"realtime_voice", "voice_io", "实时语音输入输出"},
}
type ProviderRegistry struct{}
func (r *ProviderRegistry) Dispatch(typeStr string, data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
var d struct {
Name string `json:"name"`
Description string `json:"description"`
}
json.Unmarshal(data, &d)
for _, p := range providerMap {
if p.ocType == typeStr {
toolName := fmt.Sprintf("%s_%s", pluginName, p.toolSuffix)
desc := p.desc
if d.Name != "" {
desc = fmt.Sprintf("[%s] %s", d.Name, desc)
}
tDef := sdk.ToolDef{
Name: toolName,
Description: desc,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}
handler := func(sp *sidecarProcess, ocType string) sdk.ToolHandler {
return func(args map[string]interface{}) (interface{}, error) {
return sp.CallProvider(ocType, args)
}
}(sp, typeStr)
if err := s.RegisterTool(toolName, tDef, handler); err != nil {
log.Printf("[clawhubadapter] register provider tool %s: %v", toolName, err)
}
return
}
}
log.Printf("[clawhubadapter] unknown provider type: %s (plugin: %s)", typeStr, pluginName)
}
type channelDevice struct {
name string
pluginName string
sp *sidecarProcess
ocType string
}
func (d *channelDevice) Name() string { return d.name }
func (d *channelDevice) Type() agentIO.DeviceType { return agentIO.DeviceIO }
func (d *channelDevice) Description() string { return fmt.Sprintf("OC channel %s (from %s)", d.name, d.pluginName) }
func (d *channelDevice) OutputCapabilities() agentIO.OutputCapability { return ocTypeToCap(d.ocType) }
func (d *channelDevice) Start() error { return nil }
func (d *channelDevice) Stop() error { return nil }
func (d *channelDevice) Tools() []agentIO.ToolDef { return nil }
func (d *channelDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
return d.sp.CallTool(tool, args)
}
func ocTypeToCap(t string) agentIO.OutputCapability {
switch t {
case "text":
return agentIO.CapText
case "file":
return agentIO.CapFile
case "image":
return agentIO.CapImage
case "audio":
return agentIO.CapAudio
default:
return agentIO.CapText
}
}
type ChannelRegistry struct{}
func (r *ChannelRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
var d struct {
Name string `json:"name"`
Type string `json:"type"`
}
if err := json.Unmarshal(data, &d); err != nil || d.Name == "" {
return
}
dev := &channelDevice{
name: d.Name,
pluginName: pluginName,
sp: sp,
ocType: d.Type,
}
if err := s.RegisterChannel(d.Name, dev); err != nil {
log.Printf("[clawhubadapter] register channel %s: %v", d.Name, err)
}
}
type StageRegistry struct{}
func (r *StageRegistry) Dispatch(data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
var d struct {
Name string `json:"name"`
Event string `json:"event"`
}
json.Unmarshal(data, &d)
log.Printf("[clawhubadapter] hook %s/%s (plugin: %s) — stub: OC hooks need bidirectional bridge",
d.Name, d.Event, pluginName)
}
type CapRecorder struct {
mu sync.Mutex
caps []string
}
func (r *CapRecorder) Record(typeStr string, data json.RawMessage, pluginName string) {
var d struct {
Name string `json:"name"`
Description string `json:"description"`
}
json.Unmarshal(data, &d)
capStr := fmt.Sprintf("[%s] capability: %s", pluginName, typeStr)
if d.Name != "" {
capStr += " (" + d.Name + ")"
}
if d.Description != "" {
capStr += ": " + d.Description
}
r.mu.Lock()
r.caps = append(r.caps, capStr)
r.mu.Unlock()
}
func (r *CapRecorder) Snapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.caps))
copy(out, r.caps)
return out
}
type RegistryDispatcher struct {
toolReg *ToolRegistry
providerReg *ProviderRegistry
channelReg *ChannelRegistry
stageReg *StageRegistry
capRecorder *CapRecorder
}
func NewDispatcher() *RegistryDispatcher {
return &RegistryDispatcher{
toolReg: &ToolRegistry{},
providerReg: &ProviderRegistry{},
channelReg: &ChannelRegistry{},
stageReg: &StageRegistry{},
capRecorder: &CapRecorder{},
}
}
func (d *RegistryDispatcher) Dispatch(typeStr string, data json.RawMessage, pluginName string, sp *sidecarProcess, s *sdk.PluginSDK) {
switch typeStr {
case "tool":
d.toolReg.Dispatch(data, pluginName, sp, s)
case "channel":
d.channelReg.Dispatch(data, pluginName, sp, s)
case "provider", "image_generation_provider", "music_generation_provider",
"video_generation_provider", "speech_provider",
"realtime_transcription_provider", "realtime_voice_provider",
"media_understanding_provider",
"web_fetch_provider", "web_search_provider",
"embedding_provider", "memory_embedding_provider":
d.providerReg.Dispatch(typeStr, data, pluginName, sp, s)
d.capRecorder.Record(typeStr, data, pluginName)
case "hook", "runtime_lifecycle", "lifecycle",
"agent_event_subscription", "agent_harness",
"session_event", "conversation_binding_resolved",
"interactive_handler",
"cli", "cli_backend", "node_cli_feature",
"command", "http_route", "service",
"gateway_method", "gateway_discovery_service",
"trusted_tool_policy", "tool_metadata",
"context_engine",
"memory_capability", "memory_prompt_section",
"memory_flush_plan", "memory_runtime",
"memory_prompt_supplement", "memory_corpus_supplement",
"session_extension", "session_scheduler_job",
"session_action", "control_ui_descriptor",
"agent_tool_result_middleware":
d.stageReg.Dispatch(data, pluginName, sp, s)
d.capRecorder.Record(typeStr, data, pluginName)
default:
d.capRecorder.Record(typeStr, data, pluginName)
}
}
func (d *RegistryDispatcher) Capabilities() []string {
return d.capRecorder.Snapshot()
}

View File

@ -0,0 +1,359 @@
package clawhubadapter
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
)
type sidecarRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
type sidecarResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result *json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
type OCPTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"inputSchema"`
}
type OCCallResult struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
} `json:"content"`
}
// OCNotification 是模拟器主动推送的通知
type OCNotification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
type sidecarProcess struct {
name string
dir string
cmd *exec.Cmd
stdin *bufio.Writer
mu sync.Mutex
nextID int
closed bool
stopped bool
// 异步 reader
pending map[int]chan<- []byte
notifyCh chan OCNotification
readerStop chan struct{}
readerWg sync.WaitGroup
readerReady chan struct{}
}
func newSidecarProcess(name, dir string, cmd *exec.Cmd, stdin *bufio.Writer, stdout io.Reader) *sidecarProcess {
sp := &sidecarProcess{
name: name,
dir: dir,
cmd: cmd,
stdin: stdin,
pending: make(map[int]chan<- []byte),
notifyCh: make(chan OCNotification, 1024),
readerStop: make(chan struct{}),
readerReady: make(chan struct{}),
}
sp.readerWg.Add(1)
go sp.readLoop(stdout)
<-sp.readerReady
return sp
}
func (s *sidecarProcess) readLoop(r io.Reader) {
defer s.readerWg.Done()
scanner := bufio.NewScanner(bufio.NewReader(r))
// 加大 scanner buffer 防止长行截断
scanner.Buffer(make([]byte, 0, 1024*64), 1024*64)
close(s.readerReady)
for {
select {
case <-s.readerStop:
return
default:
}
if !scanner.Scan() {
if scanner.Err() != nil {
log.Printf("[clawhubadapter] sidecar %s read error: %v", s.name, scanner.Err())
}
return
}
line := scanner.Text()
var base struct {
ID *int `json:"id"`
Method string `json:"method,omitempty"`
Error *json.RawMessage `json:"error,omitempty"`
}
if err := json.Unmarshal([]byte(line), &base); err != nil {
continue
}
if base.ID != nil {
s.mu.Lock()
ch, ok := s.pending[*base.ID]
delete(s.pending, *base.ID)
s.mu.Unlock()
if ok {
ch <- []byte(line)
close(ch)
}
} else if base.Method != "" {
var notif OCNotification
if err := json.Unmarshal([]byte(line), &notif); err == nil {
select {
case s.notifyCh <- notif:
default:
log.Printf("[clawhubadapter] sidecar %s notify channel full, dropping: %s", s.name, notif.Method)
}
}
}
}
}
func (s *sidecarProcess) NotifyChan() <-chan OCNotification {
return s.notifyCh
}
func launchSidecar(dir, name string) (*sidecarProcess, error) {
mainJS := filepath.Join(dir, "main.js")
if _, err := os.Stat(mainJS); os.IsNotExist(err) {
return nil, nil
}
return launchProcess("node", mainJS, dir, name)
}
func launchProcess(bin, arg, dir, name string) (*sidecarProcess, error) {
nodePath := bin
if bin == "node" {
if p := os.Getenv("NODE_PATH"); p != "" {
nodePath = filepath.Join(p, "node")
}
}
cmd := exec.Command(nodePath, arg, dir)
cmd.Dir = dir
cmd.Stderr = os.Stderr
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start %s: %w", name, err)
}
sp := newSidecarProcess(name, dir, cmd, bufio.NewWriter(stdin), stdout)
if err := sp.waitReady(); err != nil {
sp.Close()
return nil, err
}
return sp, nil
}
func (s *sidecarProcess) waitReady() error {
done := make(chan error, 1)
go func() {
_, err := s.call("ping", nil)
done <- err
}()
select {
case err := <-done:
return err
case <-time.After(5 * time.Second):
return fmt.Errorf("sidecar %s ping timeout", s.name)
}
}
func (s *sidecarProcess) call(method string, params interface{}) ([]byte, error) {
ch := make(chan []byte, 1)
s.mu.Lock()
if s.closed || s.stopped {
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s closed", s.name)
}
s.nextID++
id := s.nextID
s.pending[id] = ch
req := sidecarRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: params,
}
data, err := json.Marshal(req)
if err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write(data); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if _, err := s.stdin.Write([]byte("\n")); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
if err := s.stdin.Flush(); err != nil {
delete(s.pending, id)
s.mu.Unlock()
return nil, err
}
s.mu.Unlock()
select {
case raw := <-ch:
if raw == nil {
return nil, fmt.Errorf("sidecar %s error: nil response", s.name)
}
var resp sidecarResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("sidecar %s unmarshal: %w", s.name, err)
}
if resp.Error != nil {
return nil, fmt.Errorf("sidecar %s error: %s", s.name, resp.Error.Message)
}
if resp.Result == nil {
return nil, nil
}
return []byte(*resp.Result), nil
case <-time.After(30 * time.Second):
s.mu.Lock()
delete(s.pending, id)
s.mu.Unlock()
return nil, fmt.Errorf("sidecar %s call %s timeout", s.name, method)
}
}
func (s *sidecarProcess) ListTools() ([]OCPTool, error) {
data, err := s.call("tools/list", nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
var result struct {
Tools []OCPTool `json:"tools"`
}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
return result.Tools, nil
}
func (s *sidecarProcess) CallTool(name string, args map[string]interface{}) (string, error) {
data, err := s.call("tools/call", map[string]interface{}{
"name": name,
"arguments": args,
})
if err != nil {
return "", err
}
if data == nil {
return "", nil
}
var result OCCallResult
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
var sb string
for _, c := range result.Content {
if c.Type == "text" {
sb += c.Text
}
}
return sb, nil
}
func (s *sidecarProcess) CallProvider(providerType string, args map[string]interface{}) (string, error) {
data, err := s.call("provider/call", map[string]interface{}{
"type": providerType,
"action": "execute",
"args": args,
})
if err != nil {
return "", err
}
if data == nil {
return "", nil
}
var result OCCallResult
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
var sb string
for _, c := range result.Content {
if c.Type == "text" {
sb += c.Text
}
}
return sb, nil
}
func (s *sidecarProcess) Close() {
s.mu.Lock()
if s.closed || s.stopped {
s.mu.Unlock()
return
}
s.stopped = true
s.mu.Unlock()
// 先杀进程(关闭 stdout pipe),然后 reader 的 Scan() 会退出
if s.cmd != nil && s.cmd.Process != nil {
s.cmd.Process.Kill()
s.cmd.Wait()
}
// 等 reader 循环结束
s.readerWg.Wait()
s.mu.Lock()
for id, ch := range s.pending {
close(ch)
delete(s.pending, id)
}
s.mu.Unlock()
log.Printf("[clawhubadapter] sidecar %s stopped", s.name)
}

View File

@ -0,0 +1,379 @@
package clawhubadapter
import (
"fmt"
"os"
"path/filepath"
"testing"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
func TestLaunchSidecarNoMainJS(t *testing.T) {
tmpDir := t.TempDir()
sp, err := launchSidecar(tmpDir, "nonexistent")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if sp != nil {
t.Fatal("expected nil for dir without main.js")
}
}
func TestLaunchSidecarAndListTools(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join("testdata", "echoplugin", "main.js")
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read test plugin: %v", err)
}
dst := filepath.Join(tmpDir, "main.js")
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("write test plugin: %v", err)
}
sp, err := launchSidecar(tmpDir, "echoplugin")
if err != nil {
t.Fatalf("launch sidecar: %v", err)
}
defer sp.Close()
tools, err := sp.ListTools()
if err != nil {
t.Fatalf("list tools: %v", err)
}
if len(tools) == 0 {
t.Fatal("expected at least one tool")
}
found := false
for _, tool := range tools {
if tool.Name == "echo" {
found = true
if tool.Description == "" {
t.Error("expected non-empty description for echo tool")
}
}
}
if !found {
t.Fatal("expected 'echo' tool in list")
}
t.Logf("tools: %+v", tools)
}
func TestCallEchoTool(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join("testdata", "echoplugin", "main.js")
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read test plugin: %v", err)
}
dst := filepath.Join(tmpDir, "main.js")
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("write test plugin: %v", err)
}
sp, err := launchSidecar(tmpDir, "echoplugin")
if err != nil {
t.Fatalf("launch sidecar: %v", err)
}
defer sp.Close()
result, err := sp.CallTool("echo", map[string]interface{}{
"text": "hello world",
})
if err != nil {
t.Fatalf("call echo tool: %v", err)
}
expected := "Echo: hello world"
if result != expected {
t.Fatalf("expected %q, got %q", expected, result)
}
t.Logf("echo result: %s", result)
}
func TestCallAddTool(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join("testdata", "echoplugin", "main.js")
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read test plugin: %v", err)
}
dst := filepath.Join(tmpDir, "main.js")
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("write test plugin: %v", err)
}
sp, err := launchSidecar(tmpDir, "echoplugin")
if err != nil {
t.Fatalf("launch sidecar: %v", err)
}
defer sp.Close()
result, err := sp.CallTool("add", map[string]interface{}{
"a": 3.0,
"b": 4.0,
})
if err != nil {
t.Fatalf("call add tool: %v", err)
}
expected := "7"
if result != expected {
t.Fatalf("expected %q, got %q", expected, result)
}
t.Logf("add result: %s", result)
}
func TestCallNonexistentTool(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join("testdata", "echoplugin", "main.js")
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read test plugin: %v", err)
}
dst := filepath.Join(tmpDir, "main.js")
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("write test plugin: %v", err)
}
sp, err := launchSidecar(tmpDir, "echoplugin")
if err != nil {
t.Fatalf("launch sidecar: %v", err)
}
defer sp.Close()
_, err = sp.CallTool("nonexistent", nil)
if err == nil {
t.Fatal("expected error for nonexistent tool")
}
t.Logf("expected error: %v", err)
}
func TestConcurrentCalls(t *testing.T) {
tmpDir := t.TempDir()
src := filepath.Join("testdata", "echoplugin", "main.js")
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read test plugin: %v", err)
}
dst := filepath.Join(tmpDir, "main.js")
if err := os.WriteFile(dst, data, 0755); err != nil {
t.Fatalf("write test plugin: %v", err)
}
sp, err := launchSidecar(tmpDir, "echoplugin")
if err != nil {
t.Fatalf("launch sidecar: %v", err)
}
defer sp.Close()
done := make(chan bool, 5)
for i := 0; i < 5; i++ {
go func(n int) {
result, err := sp.CallTool("add", map[string]interface{}{
"a": float64(n),
"b": float64(n * 2),
})
if err != nil {
t.Errorf("concurrent call %d: %v", n, err)
}
if result != fmt.Sprintf("%d", n + n*2) {
t.Errorf("call %d: expected %d, got %s", n, n + n*2, result)
}
done <- true
}(i)
}
for i := 0; i < 5; i++ {
<-done
}
}
// ---- Simulator + OpenClaw plugin tests ----
func launchSimulator(t *testing.T, pluginDir, name string) *sidecarProcess {
t.Helper()
simPath, err := filepath.Abs(filepath.Join("simulator", "main.js"))
if err != nil {
t.Fatalf("abs simulator path: %v", err)
}
sp, err := launchProcess("node", simPath, pluginDir, name)
if err != nil {
t.Fatalf("launch simulator for %s: %v", name, err)
}
return sp
}
func TestSimulatorWithOCSimple(t *testing.T) {
pluginDir, err := filepath.Abs(filepath.Join("testdata", "oc-simple"))
if err != nil {
t.Fatalf("abs testdata: %v", err)
}
sp := launchSimulator(t, pluginDir, "oc-simple")
defer sp.Close()
tools, err := sp.ListTools()
if err != nil {
t.Fatalf("list tools: %v", err)
}
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d: %+v", len(tools), tools)
}
found := map[string]bool{"greet": false, "ping": false}
for _, tool := range tools {
found[tool.Name] = true
}
if !found["greet"] || !found["ping"] {
t.Fatalf("expected greet and ping tools, got %+v", tools)
}
result, err := sp.CallTool("greet", map[string]interface{}{"name": "Test"})
if err != nil {
t.Fatalf("call greet: %v", err)
}
if result != "Hello, Test!" {
t.Fatalf("expected 'Hello, Test!', got %q", result)
}
}
func TestSimulatorWithOCPackage(t *testing.T) {
pluginDir, err := filepath.Abs(filepath.Join("testdata", "oc-pkg"))
if err != nil {
t.Fatalf("abs testdata: %v", err)
}
sp := launchSimulator(t, pluginDir, "oc-pkg")
defer sp.Close()
tools, err := sp.ListTools()
if err != nil {
t.Fatalf("list tools: %v", err)
}
if len(tools) != 2 {
t.Fatalf("expected 2 tools, got %d: %+v", len(tools), tools)
}
found := map[string]bool{"add": false, "info": false}
for _, tool := range tools {
found[tool.Name] = true
if tool.Name == "add" {
if tool.InputSchema == nil {
t.Error("add tool should have inputSchema")
}
}
}
if !found["add"] || !found["info"] {
t.Fatalf("expected add and info tools, got %+v", tools)
}
result, err := sp.CallTool("add", map[string]interface{}{"a": 10.0, "b": 20.0})
if err != nil {
t.Fatalf("call add: %v", err)
}
if result != "30" {
t.Fatalf("expected '30', got %q", result)
}
infoResult, err := sp.CallTool("info", nil)
if err != nil {
t.Fatalf("call info: %v", err)
}
if infoResult == "" {
t.Fatal("expected non-empty info result")
}
t.Logf("info result: %s", infoResult)
}
func TestLoadOCPluginViaPluginStart(t *testing.T) {
skillsDir := t.TempDir()
ocSimpleDir := filepath.Join(skillsDir, "oc-simple")
if err := os.MkdirAll(ocSimpleDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for _, name := range []string{"openclaw.plugin.json", "index.js"} {
src := filepath.Join("testdata", "oc-simple", name)
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(ocSimpleDir, name), data, 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
ocPkgDir := filepath.Join(skillsDir, "oc-pkg")
if err := os.MkdirAll(filepath.Join(ocPkgDir, "lib"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for _, name := range []string{"package.json", "lib/entry.js"} {
src := filepath.Join("testdata", "oc-pkg", name)
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
dst := filepath.Join(ocPkgDir, name)
if err := os.WriteFile(dst, data, 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
SimulatorDir = filepath.Join(t.TempDir(), ".simulator")
p := New("openclaw", skillsDir)
var registeredTools []string
registeredHandlers := make(map[string]sdk.ToolHandler)
sdk := sdk.New("openclaw", sdk.SDKConfig{
RegTool: func(name string, def sdk.ToolDef, handler sdk.ToolHandler) error {
registeredTools = append(registeredTools, name)
registeredHandlers[name] = handler
return nil
},
})
if err := p.Start(sdk); err != nil {
t.Fatalf("start plugin: %v", err)
}
defer p.Stop()
expected := []string{"oc-simple_greet", "oc-simple_ping", "oc-pkg_add", "oc-pkg_info"}
for _, exp := range expected {
found := false
for _, name := range registeredTools {
if name == exp {
found = true
break
}
}
if !found {
t.Fatalf("expected tool %q not registered. got: %v", exp, registeredTools)
}
}
handler, ok := registeredHandlers["oc-simple_greet"]
if !ok {
t.Fatal("greet handler not registered")
}
result, err := handler(map[string]interface{}{"name": "OpenClaw"})
if err != nil {
t.Fatalf("exec greet: %v", err)
}
if result != "Hello, OpenClaw!" {
t.Fatalf("expected 'Hello, OpenClaw!', got %v", result)
}
handler, ok = registeredHandlers["oc-pkg_add"]
if !ok {
t.Fatal("add handler not registered")
}
result, err = handler(map[string]interface{}{"a": 7.0, "b": 8.0})
if err != nil {
t.Fatalf("exec add: %v", err)
}
if result != "15" {
t.Fatalf("expected '15', got %v", result)
}
}

View File

@ -0,0 +1,371 @@
const fs = require('fs');
const path = require('path');
// ---- 工具函数 ----
function writeJSON(obj) {
process.stdout.write(JSON.stringify(obj) + '\n');
}
function sendError(id, code, message) {
writeJSON({ jsonrpc: '2.0', id, error: { code, message } });
}
function readJSON(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch (e) {
return null;
}
}
function notify(method, params) {
writeJSON({ jsonrpc: '2.0', method, params });
}
// ---- 解析插件入口 ----
const pluginDir = path.resolve(process.argv[2]);
if (!pluginDir) {
process.stderr.write('[simulator] usage: node main.js <plugin-dir>\n');
process.exit(1);
}
const pkgPath = path.join(pluginDir, 'package.json');
const pkg = readJSON(pkgPath);
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) && raw.length > 0) {
for (const ext of raw) {
let ep = path.resolve(pluginDir, ext);
if (ep.endsWith('.ts')) {
const jsEp = ep.replace(/\.ts$/, '.js');
if (fs.existsSync(jsEp)) { entryPath = jsEp; 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(`[simulator] entry not found: ${entryPath}\n`);
process.exit(1);
}
// ---- 加载插件 ----
let pluginEntry;
try {
pluginEntry = require(entryPath);
} catch (e) {
process.stderr.write(`[simulator] load plugin: ${e.message}\n`);
process.exit(1);
}
const entry = pluginEntry.default || pluginEntry;
if (typeof entry !== 'object' || typeof entry.register !== 'function') {
process.stderr.write(`[simulator] plugin entry must export {default: {register(api)}}\n`);
process.exit(1);
}
// ---- 注册工具(本地存储,供 tools/list 和 tools/call 用) ----
const registeredTools = [];
// ---- Provider 存储(供 provider/call 用) ----
const registeredProviders = {}; // type -> { name, instance }
function registerTool(defOrFactory, opts) {
if (typeof defOrFactory === 'function') {
const toolCtx = {
id: 'simulator',
cwd: pluginDir,
env: process.env,
allow: ['*'],
};
const result = defOrFactory(toolCtx);
const tools = Array.isArray(result) ? result : [result];
for (const t of tools) {
if (t && typeof t.execute === 'function') {
registeredTools.push(t);
notify('register', { type: 'tool', data: { name: t.name, description: t.description, parameters: t.parameters } });
}
}
return;
}
const def = defOrFactory;
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,
});
notify('register', { type: 'tool', data: { name: def.name, label: def.label, description: def.description, parameters: def.parameters } });
}
// ---- 构造完整的 OpenClawPluginApi ----
const api = {
id: entry.id || 'unknown',
name: entry.name || 'Unknown',
version: entry.version,
description: entry.description,
source: pluginDir,
rootDir: pluginDir,
config: {},
pluginConfig: {},
registrationMode: 'full',
logger: {
debug: (...args) => {},
info: (...args) => {},
warn: (...args) => {},
error: (...args) => process.stderr.write(`[plugin] ${args.join(' ')}\n`),
},
resolvePath: (p) => path.resolve(pluginDir, p),
// ---- 工具注册 ----
registerTool,
// ---- Provider 注册(同时存储实例,支持 provider/call) ----
registerProvider: (provider) => {
if (provider && provider.id) registeredProviders['llm'] = { name: provider.id, instance: provider };
notify('register', { type: 'provider', data: { name: provider?.id || provider?.name, description: provider?.description } });
},
registerEmbeddingProvider: (p) => {
if (p) registeredProviders['embedding'] = { name: p.name, instance: p };
notify('register', { type: 'embedding_provider', data: { name: p?.name } });
},
registerSpeechProvider: (p) => {
if (p) registeredProviders['speech'] = { name: p.name, instance: p };
notify('register', { type: 'speech_provider', data: { name: p?.name } });
},
registerRealtimeTranscriptionProvider: (p) => {
if (p) registeredProviders['realtime_transcription'] = { name: p.name, instance: p };
notify('register', { type: 'realtime_transcription_provider', data: { name: p?.name } });
},
registerRealtimeVoiceProvider: (p) => {
if (p) registeredProviders['realtime_voice'] = { name: p.name, instance: p };
notify('register', { type: 'realtime_voice_provider', data: { name: p?.name } });
},
registerMediaUnderstandingProvider: (p) => {
if (p) registeredProviders['media_understanding'] = { name: p.name, instance: p };
notify('register', { type: 'media_understanding_provider', data: { name: p?.name } });
},
registerImageGenerationProvider: (p) => {
if (p) registeredProviders['image_generation'] = { name: p.name, instance: p };
notify('register', { type: 'image_generation_provider', data: { name: p?.name } });
},
registerMusicGenerationProvider: (p) => {
if (p) registeredProviders['music_generation'] = { name: p.name, instance: p };
notify('register', { type: 'music_generation_provider', data: { name: p?.name } });
},
registerVideoGenerationProvider: (p) => {
if (p) registeredProviders['video_generation'] = { name: p.name, instance: p };
notify('register', { type: 'video_generation_provider', data: { name: p?.name } });
},
registerWebFetchProvider: (p) => {
if (p) registeredProviders['web_fetch'] = { name: p.name, instance: p };
notify('register', { type: 'web_fetch_provider', data: { name: p?.name } });
},
registerWebSearchProvider: (p) => {
if (p) registeredProviders['web_search'] = { name: p.name, instance: p };
notify('register', { type: 'web_search_provider', data: { name: p?.name } });
},
registerMemoryEmbeddingProvider: (p) => {
if (p) registeredProviders['memory_embedding'] = { name: p.name, instance: p };
notify('register', { type: 'memory_embedding_provider', data: { name: p?.name } });
},
// ---- Channel 注册 ----
registerChannel: (ch) => notify('register', { type: 'channel', data: { name: ch.name, type: ch.type } }),
// ---- Hook / 生命周期 ----
registerHook: (hook) => notify('register', { type: 'hook', data: { name: hook.name, event: hook.event } }),
registerRuntimeLifecycle: (lc) => notify('register', { type: 'runtime_lifecycle', data: { name: lc.name } }),
// ---- HTTP 路由 ----
registerHttpRoute: (route) => notify('register', { type: 'http_route', data: { path: route.path, method: route.method } }),
// ---- CLI 命令 ----
registerCommand: (cmd) => notify('register', { type: 'command', data: { name: cmd.name, description: cmd.description } }),
registerCli: (cli) => notify('register', { type: 'cli', data: { name: cli.name } }),
registerCliBackend: (cb) => notify('register', { type: 'cli_backend', data: { name: cb.name } }),
registerNodeCliFeature: (f) => notify('register', { type: 'node_cli_feature', data: { name: f.name } }),
// ---- Service ----
registerService: (svc) => notify('register', { type: 'service', data: { name: svc.name } }),
// ---- Agent 相关 ----
registerAgentHarness: (h) => notify('register', { type: 'agent_harness', data: { name: h.name } }),
registerAgentToolResultMiddleware: (m) => notify('register', { type: 'agent_tool_result_middleware', data: {} }),
registerInteractiveHandler: (h) => notify('register', { type: 'interactive_handler', data: { name: h.name } }),
// ---- Gateway ----
registerGatewayMethod: (gm) => notify('register', { type: 'gateway_method', data: { name: gm.name } }),
registerGatewayDiscoveryService: (gs) => notify('register', { type: 'gateway_discovery_service', data: { name: gs.name } }),
// ---- Trust & Metadata ----
registerTrustedToolPolicy: (p) => notify('register', { type: 'trusted_tool_policy', data: { name: p.name } }),
registerToolMetadata: (m) => notify('register', { type: 'tool_metadata', data: { name: m.name } }),
// ---- Context Engine ----
registerContextEngine: (ce) => notify('register', { type: 'context_engine', data: { name: ce.name } }),
// ---- Memory 子系统 ----
registerMemoryCapability: (mc) => notify('register', { type: 'memory_capability', data: { name: mc.name } }),
registerMemoryPromptSection: (ps) => notify('register', { type: 'memory_prompt_section', data: { name: ps.name } }),
registerMemoryFlushPlan: (fp) => notify('register', { type: 'memory_flush_plan', data: { name: fp.name } }),
registerMemoryRuntime: (mr) => notify('register', { type: 'memory_runtime', data: { name: mr.name } }),
registerMemoryPromptSupplement: (ps) => notify('register', { type: 'memory_prompt_supplement', data: { name: ps.name } }),
registerMemoryCorpusSupplement: (cs) => notify('register', { type: 'memory_corpus_supplement', data: { name: cs.name } }),
// ---- 会话相关 ----
on: (event, handler) => notify('register', { type: 'session_event', data: { event } }),
onConversationBindingResolved: (handler) => notify('register', { type: 'conversation_binding_resolved', data: {} }),
session: {
state: { registerSessionExtension: (se) => notify('register', { type: 'session_extension', data: { name: se.name } }) },
workflow: {
enqueueNextTurnInjection: () => {},
registerSessionSchedulerJob: (job) => notify('register', { type: 'session_scheduler_job', data: { name: job.name } }),
sendSessionAttachment: () => {},
scheduleSessionTurn: () => {},
unscheduleSessionTurnsByTag: () => {},
},
controls: {
registerControlUiDescriptor: (d) => notify('register', { type: 'control_ui_descriptor', data: { name: d.name } }),
registerSessionAction: (a) => notify('register', { type: 'session_action', data: { name: a.name } }),
},
},
agent: {
events: {
registerAgentEventSubscription: (sub) => notify('register', { type: 'agent_event_subscription', data: { event: sub.event } }),
emitAgentEvent: (event, data) => notify('agent_event', { event, data }),
},
},
lifecycle: { registerRuntimeLifecycle: (lc) => notify('register', { type: 'lifecycle', data: { name: lc.name } }) },
runContext: {
setRunContext: () => {},
getRunContext: () => ({}),
clearRunContext: () => {},
},
runtime: {},
};
// ---- 注册插件 ----
entry.register(api);
// ---- 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' } });
return;
}
if (method === 'tools/list') {
const tools = registeredTools.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 || {};
const tool = registeredTools.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 function`);
return;
}
try {
const result = await tool.execute('sim-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;
}
// ---- Provider 调用 ----
if (method === 'provider/call') {
const { type, action, args } = req.params || {};
if (!type) { sendError(id, -32602, 'type required'); return; }
const provider = registeredProviders[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}`);
});

View File

@ -0,0 +1,106 @@
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
const tools = [
{
name: 'echo',
description: 'Echo back the input text',
inputSchema: {
type: 'object',
properties: {
text: { type: 'string', description: 'Text to echo' }
},
required: ['text']
}
},
{
name: 'add',
description: 'Add two numbers',
inputSchema: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' }
},
required: ['a', 'b']
}
},
{
name: 'ping',
description: 'Health check',
inputSchema: {
type: 'object',
properties: {}
}
}
];
rl.on('line', (line) => {
let req;
try {
req = JSON.parse(line);
} catch (e) {
sendError(null, -32700, 'Parse error');
return;
}
const id = req.id;
const method = req.method;
if (method === 'ping') {
sendResult(id, { status: 'ok' });
return;
}
if (method === 'tools/list') {
sendResult(id, { tools });
return;
}
if (method === 'tools/call') {
const params = req.params || {};
const toolName = params.name;
const args = params.arguments || {};
const tool = tools.find(t => t.name === toolName);
if (!tool) {
sendError(id, -32601, `Tool not found: ${toolName}`);
return;
}
let result;
switch (toolName) {
case 'echo':
result = { content: [{ type: 'text', text: `Echo: ${args.text || ''}` }] };
break;
case 'add':
const sum = (Number(args.a) || 0) + (Number(args.b) || 0);
result = { content: [{ type: 'text', text: `${sum}` }] };
break;
default:
sendError(id, -32601, `Not implemented: ${toolName}`);
return;
}
sendResult(id, result);
return;
}
sendError(id, -32601, `Method not found: ${method}`);
});
function sendResult(id, result) {
const resp = { jsonrpc: '2.0', id, result };
process.stdout.write(JSON.stringify(resp) + '\n');
}
function sendError(id, code, message) {
const resp = { jsonrpc: '2.0', id, error: { code, message } };
process.stdout.write(JSON.stringify(resp) + '\n');
}
// Sidecar is ready - no startup message needed

View File

@ -0,0 +1,33 @@
module.exports = {
default: {
id: 'oc-pkg',
name: 'OC Package',
version: '2.0.0',
description: 'Test plugin discovered via package.json extensions',
register(api) {
api.registerTool({
name: 'add',
description: 'Add two numbers',
parameters: {
type: 'object',
properties: {
a: { type: 'number', description: 'First number' },
b: { type: 'number', description: 'Second number' },
},
required: ['a', 'b'],
},
execute(id, params) {
return String(params.a + params.b);
},
});
api.registerTool({
name: 'info',
description: 'Return plugin info',
execute(id, params) {
return JSON.stringify({ id: this.id, name: this.name, version: this.version });
},
});
},
},
};

View File

@ -0,0 +1,8 @@
{
"name": "oc-pkg",
"version": "2.0.0",
"description": "OC plugin with package.json entry discovery",
"openclaw": {
"extensions": ["lib/entry.js"]
}
}

View File

@ -0,0 +1,31 @@
module.exports = {
default: {
id: 'oc-simple',
name: 'OC Simple',
version: '1.0.0',
description: 'Simple test plugin for OpenClaw simulator',
register(api) {
api.registerTool({
name: 'greet',
description: 'Greet someone',
parameters: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name to greet' },
},
required: ['name'],
},
execute(id, params) {
return `Hello, ${params.name}!`;
},
});
api.registerTool({
name: 'ping',
description: 'Health check ping',
execute(id, params) {
return 'pong';
},
});
},
},
};

View File

@ -0,0 +1,6 @@
{
"name": "oc-simple",
"version": "1.0.0",
"entry": "index.js",
"description": "Simple OpenClaw test plugin"
}