Complete rewrite of the Dirigent plugin turn management system to work correctly with OpenClaw's VM-context-per-session architecture: - All turn state stored on globalThis (persists across VM context hot-reloads) - Hooks registered unconditionally on every api instance; event-level dedup (runId Set for agent_end, WeakSet for before_model_resolve) prevents double-processing - Gateway lifecycle events (gateway_start/stop) guarded once via globalThis flag - Shared initializingChannels lock prevents concurrent channel init across VM contexts in message_received and before_model_resolve - New ChannelStore and IdentityRegistry replace old policy/session-state modules - Added agent_end hook with tail-match polling for Discord delivery confirmation - Added web control page, padded-cell auto-scan, discussion tool support - Removed obsolete v1 modules: channel-resolver, channel-modes, discussion-service, session-state, turn-bootstrap, policy/store, rules, decision-input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
16 KiB
Dirigent — Design Spec (v2)
Overview
Dirigent is an OpenClaw plugin that orchestrates turn-based multi-agent conversations in Discord. It manages who speaks when, prevents out-of-turn responses, and coordinates structured discussions between agents.
Optional integrations (Dirigent must function fully without either):
- padded-cell — enables auto-registration of agent identities from
ego.json - yonexus — enables cross-instance multi-agent coordination (see §8)
1. Identity Registry
Storage
A JSON file (path configurable via plugin config, default ~/.openclaw/dirigent-identity.json).
Each entry:
{
"discordUserId": "123456789012345678",
"agentId": "home-developer",
"agentName": "Developer"
}
Registration Methods
Manual — Tool
Agents call dirigent-register to add or update their own entry. agentId is auto-derived from the calling session; the agent only provides discordUserId and optionally agentName.
Manual — Control Page
The /dirigent control page exposes a table with inline add, edit, and delete.
Auto — padded-cell Integration
On gateway startup, if padded-cell is loaded, Dirigent reads ~/.openclaw/ego.json.
Detection: check whether ego.json's columns array contains "discord-id". If not, treat padded-cell as absent and skip auto-registration entirely.
ego.json structure (padded-cell's EgoData format):
{
"columns": ["discord-id", "..."],
"publicColumns": ["..."],
"publicScope": {},
"agentScope": {
"home-developer": { "discord-id": "123456789012345678" },
"home-researcher": { "discord-id": "987654321098765432" }
}
}
Scan logic:
- If
columnsdoes not include"discord-id": skip entirely. - For each key in
agentScope: key is theagentId. - Read
agentScope[agentId]["discord-id"]. If present and non-empty: upsert into identity registry (existing entries preserved, new ones appended). - Agent name defaults to
agentIdif no dedicated name column exists.
The control page shows a Re-scan padded-cell button when padded-cell is detected.
2. Channel Modes
Default: any channel Dirigent has not seen before is treated as none.
| Mode | Description | How to set |
|---|---|---|
none |
No special behavior. Turn-manager disabled. | Default · /set-channel-mode none · control page |
work |
Agent workspace channel. Turn-manager disabled. | create-work-channel tool only |
report |
Agents post via message tool only; not woken by incoming messages. | create-report-channel tool · /set-channel-mode report · control page |
discussion |
Structured agent discussion. | create-discussion-channel tool only |
chat |
Ongoing multi-agent chat. | create-chat-channel tool · /set-channel-mode chat · control page |
Mode-change restrictions:
workanddiscussionare locked — only settable at channel creation by their respective tools. Cannot be changed to another mode; no other mode can be changed to them.none,chat, andreportare freely switchable via/set-channel-modeor the control page.
Mode → Turn-Manager State
| Mode | Agent Count | Turn-Manager State |
|---|---|---|
none |
any | disabled |
work |
any | disabled |
report |
any | dead |
discussion |
1 | disabled |
discussion |
2 | normal |
discussion |
3+ | shuffle |
discussion |
concluded | archived |
chat |
1 | disabled |
chat |
2 | normal |
chat |
3+ | shuffle |
3. Channel Creation Tools & Slash Commands
Tools
create-chat-channel
Creates a new Discord channel in the caller's guild and sets its mode to chat.
| Parameter | Description |
|---|---|
name |
Channel name |
participants |
Discord user IDs to add (optional; moderator bot always added) |
create-report-channel
Creates a new Discord channel and sets its mode to report.
| Parameter | Description |
|---|---|
name |
Channel name |
members |
Discord user IDs to add (optional) |
create-work-channel
Creates a new Discord channel and sets its mode to work. Mode is permanently locked.
| Parameter | Description |
|---|---|
name |
Channel name |
members |
Additional Discord user IDs to add (optional) |
create-discussion-channel
See §5 for full details.
dirigent-register
Registers or updates the calling agent's identity entry.
| Parameter | Description |
|---|---|
discordUserId |
The agent's Discord user ID |
agentName |
Display name (optional; defaults to agentId) |
Slash Command — /set-channel-mode
Available in any Discord channel where the moderator bot is present.
/set-channel-mode <mode>
- Allowed values:
none,chat,report - Rejected with error:
work,discussion(locked to creation tools) - If the channel is currently
workordiscussion: command is rejected, mode is locked
4. Turn-Manager
Per-Channel States
| State | Behavior |
|---|---|
disabled |
All turn-manager logic bypassed. Agents respond normally. |
dead |
Discord messages are not routed to any agent session. |
normal |
Speaker list rotates in fixed order. |
shuffle |
After the last speaker completes a full cycle, the list is reshuffled. Constraint: the previous last speaker cannot become the new first speaker. |
archived |
Channel is sealed. No agent is woken. New Discord messages receive a moderator auto-reply: "This channel is archived and no longer active." |
Speaker List Construction
For discussion and chat channels:
- Moderator bot fetches all Discord channel members via Discord API.
- Each member's Discord user ID is resolved via the identity registry. Members identified as agents are added to the speaker list.
- At each cycle boundary (after the last speaker in the list completes their turn), the list is rebuilt:
- Re-fetch current Discord channel members.
- In
normalmode: existing members retain relative order; new agents are appended. - In
shufflemode: the rebuilt list is reshuffled, with the constraint above.
Turn Flow
before_model_resolve
- Determine the active speaker for this channel (from turn-manager state).
- Record the current channel's latest Discord message ID as an anchor (used later for delivery confirmation).
- If the current agent is the active speaker: allow through with their configured model.
- If not: route to
dirigent/no-reply— response is suppressed.
agent_end
- Check if the agent that finished is the active speaker. If not: ignore.
- Extract the final reply text from
event.messages: find the last message withrole === "assistant", then concatenate thetextfield from all{type: "text"}parts in itscontentarray. - Classify the turn:
- Empty turn: text is
NO_REPLY,NO, or empty/whitespace-only. - Real turn: anything else.
- Empty turn: text is
- Record the result for dormant tracking.
If empty turn: advance the speaker pointer immediately — no Discord delivery to wait for.
If real turn: wait for Discord delivery confirmation before advancing.
Delivery Confirmation (Real Turns)
agent_end fires when OpenClaw has dispatched the message, not when Discord has delivered it. OpenClaw also splits long messages into multiple Discord messages — the next agent must not be triggered before the last fragment arrives.
Tail-match polling:
- Take the last 40 characters of the final reply text as a tail fingerprint.
- Poll
GET /channels/{channelId}/messages?limit=20at a short interval, filtering to messages where:message.id > anchor(only messages from this turn onward)message.author.id === agentDiscordUserId(only from this agent's Discord account)
- Take the most recent matching message. If its content ends with the tail fingerprint: match confirmed.
- On match: advance the speaker pointer and post
{schedule_identifier}then immediately delete it.
Interruption: if any message from a non-current-speaker appears in the channel during the wait, cancel the tail-match and treat the event as a wake-from-dormant (see below).
Timeout: if no match within 15 seconds (configurable), log a warning and advance anyway to prevent a permanently stalled turn.
Fingerprint length: 40 characters (configurable). The author + anchor filters make false matches negligible at this length.
Dormant Stage
Definitions
- Cycle: one complete pass through the current speaker list from first to last.
- Empty turn: final reply text is
NO_REPLY,NO, or empty/whitespace-only. - Cycle boundary: the moment the last agent in the current list completes their turn.
Intent
Dormant stops the moderator from endlessly triggering agents when no one has anything to say. Entering dormant requires unanimous empty turns — any single real message is a veto and the cycle continues. When a new Discord message arrives (from a human or an agent via the message tool), it signals a new topic; the channel wakes and every agent gets another chance to respond.
Trigger
At each cycle boundary:
- Re-fetch Discord channel members and build the new speaker list.
- Check whether any new agents were added to the list.
- Check whether all agents who completed a turn in this cycle sent empty turns.
Enter dormant only if both hold:
- All agents in the completed cycle sent empty turns.
- No new agents were added at this boundary.
If new agents joined: reset empty-turn tracking and start a fresh cycle — do not enter dormant even if all existing agents sent empty.
Dormant Behavior
currentSpeaker→null.- Empty-turn history is cleared.
- Moderator stops posting
{schedule_identifier}.
Wake from Dormant
- Trigger: any new Discord message in the channel (human or agent via message tool).
currentSpeaker→ first agent in the speaker list.- Moderator posts
{schedule_identifier}then deletes it. - A new cycle begins. Agents that have nothing to say emit empty turns; if all pass again, the channel returns to dormant.
Edge Cases
| Scenario | Behavior |
|---|---|
| Agent leaves mid-cycle | Turn is skipped; agent removed at next cycle boundary. Dormant check counts only agents who completed a turn. |
| New agent joins mid-cycle | Not added until next cycle boundary. Does not affect current dormant check. |
| Shuffle mode | Reshuffle happens after the dormant check at cycle boundary. Dormant logic is identical to normal. |
| Shuffle + new agents | New agents appended before reshuffling. Since new agents were found, dormant is suppressed; full enlarged list starts a new shuffled cycle. |
5. Discussion Mode
Creation — create-discussion-channel
Called by an agent (the initiator). initiator is auto-derived from the calling session.
| Parameter | Description |
|---|---|
callback-guild |
Guild ID of the initiator's current channel. Error if moderator bot lacks admin in this guild. |
callback-channel |
Channel ID of the initiator's current channel. Error if not a Discord group channel. |
discussion-guide |
Minimum context: topic, goals, completion criteria. |
participants |
List of Discord user IDs for participating agents. |
Discussion Lifecycle
Agent calls create-discussion-channel
│
▼
Moderator creates new private Discord channel, adds participants
│
▼
Moderator posts discussion-guide into the channel → wakes participant agents
│
▼
Turn-manager governs the discussion (normal / shuffle based on participant count)
│
├─[dormant]──► Moderator posts reminder to initiator:
│ "Discussion is idle. Please summarize and call discussion-complete."
│
▼ initiator calls discussion-complete
Turn-manager state → archived
Moderator auto-replies to any new messages: "This discussion is closed."
│
▼
Moderator posts summary file path to callback-channel
discussion-complete Tool
| Parameter | Description |
|---|---|
discussion-channel |
Channel ID where the discussion took place |
summary |
File path to the summary (must be under {workspace}/discussion-summary/) |
Validation:
- Caller must be the initiator of the specified discussion channel. Otherwise: error.
- Summary file must exist at the given path.
6. Control Page — /dirigent
HTTP route registered on the OpenClaw gateway. Auth: gateway (requires the same Bearer token as the gateway API; returns 401 without it).
Sections
Identity Registry
- Table: discord-user-id / agent-id / agent-name
- Inline add, edit, delete
- Re-scan padded-cell button (shown only when padded-cell is detected)
Guild & Channel Configuration
- Lists all Discord guilds where the moderator bot has admin permissions.
- For each guild: all private group channels.
- Per channel:
- Current mode badge
- Mode dropdown (
none | chat | report) — hidden forworkanddiscussionchannels workanddiscussionchannels display mode as a read-only badge- Channels unknown to Dirigent display as
none - Current turn-manager state and active speaker name (where applicable)
7. Migration from v1
| v1 Mechanic | v2 Replacement |
|---|---|
End symbol (🔚) required in agent replies |
Removed — agents no longer need end symbols |
before_message_write drives turn advance |
Replaced by agent_end hook |
| Moderator posts visible handoff message each turn | Moderator posts {schedule_identifier} then immediately deletes it |
NO_REPLY detected from before_message_write content |
Derived from last assistant message in agent_end event.messages |
| Turn advances immediately on agent response | Empty turns advance immediately; real turns wait for Discord delivery confirmation via tail-match polling |
8. Yonexus Compatibility (Future)
Yonexus is a planned cross-instance WebSocket communication plugin (hub-and-spoke). Dirigent must work fully without it.
Topology
Instance A (master) Instance B (slave) Instance C (slave)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Dirigent │◄──Yonexus──►│ Dirigent │◄──Yonexus──►│ Dirigent │
│ (authority) │ │ (relay) │ │ (relay) │
└──────────────┘ └──────────────┘ └──────────────┘
│
Authoritative state:
- Identity registry
- Channel modes & turn-manager states
- Speaker lists & turn pointers
- Discussion metadata
Master / Slave Roles
Master:
- Holds all authoritative state.
- Serves read/write operations to slaves via Yonexus message rules.
- Executes all moderator bot actions (post/delete
{schedule_identifier}, send discussion-guide, etc.).
Slave:
- No local state for shared channels.
before_model_resolve: queries master to determine if this agent is the active speaker.agent_end: notifies master that the turn is complete (agentId,channelId,isEmpty).- Master handles all speaker advancement and moderator actions.
Message Rules (provisional)
dirigent::check-turn → { allowed: bool, currentSpeaker: string }
dirigent::turn-complete → { agentId, channelId, isEmpty }
dirigent::get-identity → identity registry entry for discordUserId
dirigent::get-channel-state → { mode, tmState, currentSpeaker }
Constraints
- Without Yonexus: Dirigent runs in standalone mode with all state local.
- Role configured via plugin config:
dirigentRole: "master" | "slave"(default:"master"). - Slave instances skip all local state mutations.
- Identity registry, channel config, and control page are only meaningful on the master instance.