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

@@ -176,8 +176,6 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
return;
}
clearTurnPending(channelId, agentId);
api.logger.info(
`dirigent: agent_end channel=${channelId} agentId=${agentId} empty=${empty} text=${finalText.slice(0, 80)}`,
);
@@ -186,6 +184,10 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
// Real turn: wait for Discord delivery before triggering next speaker.
// Anchor was set in before_model_resolve just before the LLM call, so any
// message from the agent after the anchor must be from this turn.
// NOTE: clearTurnPending is intentionally deferred until after pollForTailMatch
// returns. While waiting, isTurnPending remains true so that any re-trigger of
// this agent is correctly treated as a self-wakeup (suppressed), preventing it
// from starting a second real turn during the tail-match window.
const identity = identityRegistry.findByAgentId(agentId);
if (identity && moderatorBotToken) {
const anchorId = getAnchor(channelId, agentId) ?? "0";
@@ -202,6 +204,7 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
if (isDormant(channelId)) {
// Channel is dormant: a new external message woke it — restart from first speaker
api.logger.info(`dirigent: tail-match interrupted (dormant) channel=${channelId} — waking`);
clearTurnPending(channelId, agentId);
const first = wakeFromDormant(channelId);
if (first) await triggerNextSpeaker(channelId, first);
return;
@@ -243,6 +246,12 @@ export function registerAgentEndHook(deps: AgentEndDeps): InterruptFn {
advancingChannels.delete(channelId);
}
// Clear turn pending AFTER advanceSpeaker completes. This ensures isTurnPending
// remains true during the async rebuildFn window at cycle boundaries, preventing
// re-triggers from starting a second real turn while currentIndex is still at the
// outgoing speaker's position.
clearTurnPending(channelId, agentId);
if (enteredDormant) {
api.logger.info(`dirigent: channel=${channelId} entered dormant`);
if (mode === "discussion") {