refactor: new design — sidecar services, moderator Gateway client, tool execute API

- Replace standalone no-reply-api Docker service with unified sidecar (services/main.mjs)
  that routes /no-reply/* and /moderator/* and starts/stops with openclaw-gateway
- Add moderator Discord Gateway client (services/moderator/index.mjs) for real-time
  MESSAGE_CREATE push instead of polling; notifies plugin via HTTP callback
- Add plugin HTTP routes (plugin/web/dirigent-api.ts) for moderator → plugin callbacks
  (wake-from-dormant, interrupt tail-match)
- Fix tool registration format: AgentTool requires execute: not handler:; factory form
  for tools needing ctx
- Rename no-reply-process.ts → sidecar-process.ts, startNoReplyApi → startSideCar
- Remove dead config fields from openclaw.plugin.json (humanList, agentList, listMode,
  channelPoliciesFile, endSymbols, waitIdentifier, multiMessage*, bypassUserIds, etc.)
- Rename noReplyPort → sideCarPort
- Remove docker-compose.yml, dev-up/down scripts, package-plugin.mjs, test-no-reply-api.mjs
- Update install.mjs: clean dist before build, copy services/, drop dead config writes
- Update README, Makefile, smoke script for new architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-10 08:07:59 +01:00
parent d8ac9ee0f9
commit 32dc9a4233
28 changed files with 1310 additions and 900 deletions

View File

@@ -1,20 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(process.cwd(), '..');
const pluginDir = path.join(root, 'plugin');
const required = ['index.ts', 'rules.ts', 'openclaw.plugin.json', 'README.md', 'package.json'];
const root = path.resolve(import.meta.dirname, '..');
const checks = [
// Core plugin files
path.join(root, 'plugin', 'index.ts'),
path.join(root, 'plugin', 'turn-manager.ts'),
path.join(root, 'plugin', 'openclaw.plugin.json'),
path.join(root, 'plugin', 'package.json'),
// Sidecar
path.join(root, 'services', 'main.mjs'),
path.join(root, 'services', 'no-reply-api', 'server.mjs'),
path.join(root, 'services', 'moderator', 'index.mjs'),
];
let ok = true;
for (const f of required) {
const p = path.join(pluginDir, f);
for (const p of checks) {
if (!fs.existsSync(p)) {
ok = false;
console.error(`missing: ${p}`);
}
}
const manifestPath = path.join(pluginDir, 'openclaw.plugin.json');
const manifestPath = path.join(root, 'plugin', 'openclaw.plugin.json');
if (fs.existsSync(manifestPath)) {
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
for (const k of ['id', 'entry', 'configSchema']) {

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
docker compose down

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"
echo "[dirigent] building/starting no-reply API container"
docker compose up -d --build dirigent-no-reply-api
echo "[dirigent] health check"
curl -sS http://127.0.0.1:8787/health
echo "[dirigent] done"

View File

@@ -120,7 +120,7 @@ const PLUGIN_SKILLS_DIR = path.join(REPO_ROOT, "skills");
const NO_REPLY_PROVIDER_ID = process.env.NO_REPLY_PROVIDER_ID || "dirigent";
const NO_REPLY_MODEL_ID = process.env.NO_REPLY_MODEL_ID || "no-reply";
const NO_REPLY_PORT = Number(process.env.NO_REPLY_PORT || argNoReplyPort);
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/v1`;
const NO_REPLY_BASE_URL = process.env.NO_REPLY_BASE_URL || `http://127.0.0.1:${NO_REPLY_PORT}/no-reply/v1`;
const NO_REPLY_API_KEY = process.env.NO_REPLY_API_KEY || "wg-local-test-token";
function runOpenclaw(args, allowFail = false) {
@@ -143,10 +143,11 @@ if (mode === "install") {
step(1, 7, "build dist assets");
const pluginSrc = path.resolve(REPO_ROOT, "plugin");
const noReplySrc = path.resolve(REPO_ROOT, "no-reply-api");
const sidecarSrc = path.resolve(REPO_ROOT, "services");
const distPlugin = path.resolve(REPO_ROOT, "dist", "dirigent");
fs.rmSync(distPlugin, { recursive: true, force: true });
syncDirRecursive(pluginSrc, distPlugin);
syncDirRecursive(noReplySrc, path.join(distPlugin, "no-reply-api"));
syncDirRecursive(sidecarSrc, path.join(distPlugin, "services"));
ok("dist assets built");
step(2, 7, `install plugin files -> ${PLUGIN_INSTALL_DIR}`);
@@ -187,17 +188,10 @@ if (mode === "install") {
}
setIfMissing("plugins.entries.dirigent.enabled", true);
const cp = "plugins.entries.dirigent.config";
setIfMissing(`${cp}.enabled`, true);
setIfMissing(`${cp}.discordOnly`, true);
setIfMissing(`${cp}.listMode`, "human-list");
setIfMissing(`${cp}.humanList`, []);
setIfMissing(`${cp}.agentList`, []);
setIfMissing(`${cp}.channelPoliciesFile`, path.join(OPENCLAW_DIR, "dirigent-channel-policies.json"));
setIfMissing(`${cp}.endSymbols`, ["🔚"]);
setIfMissing(`${cp}.schedulingIdentifier`, "➡️");
setIfMissing(`${cp}.scheduleIdentifier`, "➡️");
setIfMissing(`${cp}.noReplyProvider`, NO_REPLY_PROVIDER_ID);
setIfMissing(`${cp}.noReplyModel`, NO_REPLY_MODEL_ID);
setIfMissing(`${cp}.noReplyPort`, NO_REPLY_PORT);
setIfMissing(`${cp}.sideCarPort`, NO_REPLY_PORT);
// moderatorBotToken: intentionally not touched — set manually via:
// openclaw config set plugins.entries.dirigent.config.moderatorBotToken "<token>"
ok("plugin configured");

View File

@@ -1,15 +0,0 @@
import fs from "node:fs";
import path from "node:path";
const root = process.cwd();
const pluginDir = path.join(root, "plugin");
const outDir = path.join(root, "dist", "dirigent");
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
for (const f of ["index.ts", "rules.ts", "turn-manager.ts", "moderator-presence.ts", "openclaw.plugin.json", "README.md", "package.json"]) {
fs.copyFileSync(path.join(pluginDir, f), path.join(outDir, f));
}
console.log(`packaged plugin to ${outDir}`);

View File

@@ -1,32 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8787}"
AUTH_TOKEN="${AUTH_TOKEN:-}"
AUTH_HEADER=()
if [[ -n "$AUTH_TOKEN" ]]; then
AUTH_HEADER=(-H "Authorization: Bearer ${AUTH_TOKEN}")
fi
# Smoke-tests the no-reply API endpoint exposed by the sidecar service.
# The sidecar must already be running (it starts automatically with openclaw-gateway).
# Default base URL matches the sidecar's no-reply prefix.
BASE_URL="${BASE_URL:-http://127.0.0.1:8787/no-reply}"
echo "[1] health"
curl -sS "${BASE_URL}/health" | sed -n '1,3p'
curl -fsS "${BASE_URL}/health"
echo ""
echo "[2] models"
curl -sS "${BASE_URL}/v1/models" "${AUTH_HEADER[@]}" | sed -n '1,8p'
curl -fsS "${BASE_URL}/v1/models" | head -c 200
echo ""
echo "[3] chat/completions"
curl -sS -X POST "${BASE_URL}/v1/chat/completions" \
curl -fsS -X POST "${BASE_URL}/v1/chat/completions" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"dirigent-no-reply-v1","messages":[{"role":"user","content":"hello"}]}' \
| sed -n '1,20p'
-d '{"model":"no-reply","messages":[{"role":"user","content":"hello"}]}' \
| head -c 300
echo ""
echo "[4] responses"
curl -sS -X POST "${BASE_URL}/v1/responses" \
curl -fsS -X POST "${BASE_URL}/v1/responses" \
-H 'Content-Type: application/json' \
"${AUTH_HEADER[@]}" \
-d '{"model":"dirigent-no-reply-v1","input":"hello"}' \
| sed -n '1,20p'
-d '{"model":"no-reply","input":"hello"}' \
| head -c 300
echo ""
echo "smoke ok"

View File

@@ -1,82 +0,0 @@
import { spawn } from "node:child_process";
const BASE = "http://127.0.0.1:18787";
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function waitForHealth(retries = 30) {
for (let i = 0; i < retries; i++) {
try {
const r = await fetch(`${BASE}/health`);
if (r.ok) return true;
} catch {}
await sleep(200);
}
return false;
}
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function run() {
const token = "test-token";
const child = spawn("node", ["no-reply-api/server.mjs"], {
cwd: process.cwd(),
env: { ...process.env, PORT: "18787", AUTH_TOKEN: token, NO_REPLY_MODEL: "wg-test-model" },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
try {
const ok = await waitForHealth();
assert(ok, "health check failed");
const unauth = await fetch(`${BASE}/v1/models`);
assert(unauth.status === 401, `expected 401, got ${unauth.status}`);
const models = await fetch(`${BASE}/v1/models`, {
headers: { Authorization: `Bearer ${token}` },
});
assert(models.ok, "authorized /v1/models failed");
const modelsJson = await models.json();
assert(modelsJson?.data?.[0]?.id === "wg-test-model", "model id mismatch");
const cc = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", messages: [{ role: "user", content: "hi" }] }),
});
assert(cc.ok, "chat completions failed");
const ccJson = await cc.json();
assert(ccJson?.choices?.[0]?.message?.content === "NO_REPLY", "chat completion not NO_REPLY");
const rsp = await fetch(`${BASE}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ model: "wg-test-model", input: "hi" }),
});
assert(rsp.ok, "responses failed");
const rspJson = await rsp.json();
assert(rspJson?.output?.[0]?.content?.[0]?.text === "NO_REPLY", "responses not NO_REPLY");
console.log("test-no-reply-api: ok");
} finally {
child.kill("SIGTERM");
}
}
run().catch((err) => {
console.error(`test-no-reply-api: fail: ${err.message}`);
process.exit(1);
});