Instead of spawning separate processes or using OpenRouter, MoA now:
- Queries external API models (Kimi, GLM) in parallel for opinions
- Injects opinions into the SDK arbiter's prompt
- The existing subscription-based arbiter aggregates all perspectives
No extra SDK processes, no OpenRouter dependency. Per-model config via
MOA_REF_MODELS + MOA_{NAME}_MODEL/BASE_URL/API_KEY env vars.
When MOA_ENABLED=true, arbiter queries multiple LLM models in parallel
via OpenAI-compatible API, then an aggregator model synthesizes the
final verdict from all opinions. Falls back to single-agent arbiter
when MoA is disabled.
Config: MOA_BASE_URL, MOA_API_KEY, MOA_REFERENCE_MODELS, MOA_AGGREGATOR_MODEL
- Replace better-sqlite3 with bun:sqlite (native, no native addon build)
- Change all spawn('node') to spawn('bun') for agent processes
- Update package.json scripts: node→bun, tsx→bun, npm→bun
- Add bun-types for tsc compatibility
- Add bun:sqlite→better-sqlite3 shim for vitest (tests run on Node.js)
- Update Dockerfile: install bun alongside Node.js (CLIs need Node)
- Update setup/platform.ts: getNodePath() resolves bun binary
- Remove better-sqlite3 from production dependencies (devDep only for tests)
SERVICE_AGENT_TYPE was always 'claude-code' in the unified service,
making it a misleading constant. Each group already has its own
agentType field.
- Removed SERVICE_AGENT_TYPE from config.ts
- Session functions now accept agentType parameter (default: 'claude-code')
- Task scheduler uses task-level agent_type for token rotation decisions
- All fallback defaults changed to 'claude-code' literal
- Logging uses 'unified' instead of the misleading type
Eliminated duplicate task status checks in message-agent-executor.ts
and message-runtime.ts. All status→role decisions now flow through
the single resolveActiveRole() function.
resolveCursorKey and resolveChannel both duplicated the same
taskStatus→role switch. Now resolveActiveRole() is the single
mapping, and both functions derive from it.
Replace all manual channel selection (useReviewerChannel,
useArbiterChannel, isReviewerWorkItem, isArbiterWorkItem, 3-way
ternary chains) with a single resolveChannel(taskStatus) function
that maps task status to the correct Discord bot channel.
Replace all manual cursor key construction (hardcoded chatJid:reviewer,
chatJid:arbiter, pairedCursorKey, 3-way ternary) with a single
resolveCursorKey(chatJid, taskStatus) function.
- Removed pairedCursorKey import (no longer needed)
- Removed loopIsReviewerTurn, loopIsArbiterTurn intermediate variables
- All cursor key logic now flows through one function that maps
task status to the correct role-specific key
The review_ready and arbiter_requested paths in processGroupMessages
were advancing the default (owner) cursor instead of the role-specific
cursor. This caused the reviewer to miss the owner's messages because
the owner cursor had already moved past them.
Now review_ready advances chatJid:reviewer cursor only, and
arbiter_requested advances chatJid:arbiter cursor only.
BLOCKED and NEEDS_CONTEXT verdicts went straight to completed,
bypassing the arbiter entirely. Now when arbiter is enabled, these
verdicts trigger arbiter intervention first — the arbiter may
resolve the block or decide to escalate to the user (ESCALATE).
When OAuth tokens rotate or refresh, all reviewer containers are
automatically removed. The next reviewer turn recreates them with
the latest token, preventing 401 auth failures.
Uses callback pattern to avoid circular dependencies:
- token-rotation.ts: onTokenRotated(cb)
- token-refresh.ts: onTokenRefreshed(cb)
- index.ts: registers recreateAllReviewerContainers as callback
The counter was being reset to 0 on every owner turn, including
bot-only ping-pong turns. This prevented the 3-round-trip echo
loop breaker from ever triggering. Now only human messages reset
the counter.
When both owner and reviewer keep echoing DONE_WITH_CONCERNS without
making progress, the system now stops the ping-pong after 3 round
trips and sets the task to completed, escalating to the user.
Owner was "agreeing" with concerns instead of fixing code. Reviewer
was listing issues without directing the owner to fix them. Now:
- Reviewer: explicitly direct the owner to fix specific issues
- Owner: fix issues when flagged, don't just acknowledge
Both agents were repeating "agreed, 3 concerns remain" without
taking action. Now DONE_WITH_CONCERNS requires listing specific
actions, and repeating the same concerns for 2+ turns triggers
BLOCKED escalation.
Reviewer failover handoffs were routed through the owner channel,
causing codex-review to run in owner mode instead of reviewer mode.
Now detects reviewer handoffs by reason prefix and routes them
through the correct reviewer channel.
The unified service runs all three bots (claude, codex-main,
codex-review) in one process but only queried handoffs for SERVICE_ID
(claude). Reviewer failover handoffs targeting codex-review were
never picked up. Now queries all three service IDs.
Root cause: owner and reviewer shared the same message cursor. When
the owner's turn completed, the cursor advanced past the owner's
message. The reviewer's turn then only saw messages after that cursor,
missing the owner's response entirely.
Now paired rooms use role-aware cursor keys (chatJid for owner,
chatJid:reviewer for reviewer). Each role advances only its own
cursor, so the reviewer always sees the owner's latest messages
and vice versa.
The finalize turn (auto-triggered after reviewer approval) was being
treated as a new working turn because merge_ready was unconditionally
reset to active. Now merge_ready is only reset when a human message
is in the batch, preserving the finalize flow for bot-only turns.
In paired rooms, bot messages now show sender as "오너" or "리뷰어"
instead of the Discord nickname. Uses channel.isOwnMessage() to
identify which bot sent each message, then maps via the channel
lease to determine the role. This ensures agents correctly identify
who is the owner and who is the reviewer regardless of bot nicknames.
Discord file attachments are downloaded to data/attachments/ on the
host. Mount this directory read-only in the reviewer container at the
same absolute path so both owner and reviewer can access uploaded
files (WAV, images, etc.) using the same path.
Voice messages (Discord IsVoiceMessage flag or attachment with duration)
are still transcribed via Whisper. Regular audio file attachments (WAV,
MP3, etc.) are now downloaded to disk and the path is passed to the
agent, matching image attachment behavior.
When the reviewer is Claude and hits auth/429/overloaded errors,
hand off the review turn to codex-review instead of swapping
owner/reviewer roles (which is the owner failover path).
When a user sends a new message while the task is in merge_ready,
review_ready, or in_review status, the owner's turn was incorrectly
treated as a finalize turn. This caused premature task completion
(DONE → completed) even when the user wanted to continue working.
Now resets status to active alongside round_trip_count, ensuring
the owner gets a fresh working turn instead of a finalize turn.
The credential proxy cached the OAuth token at startup, but Anthropic
no longer supports raw OAuth Bearer tokens on the API. The Claude Code
SDK handles OAuth internally, so the proxy was breaking auth.
Now injects the real token both at container creation and at each
docker exec, so token rotation is picked up without recreating the
container.
When the owner registers a CI watcher and completes the turn, the
review loop no longer triggers. The watcher result will be posted
via the reviewer bot, which the owner picks up and acts on, then
the normal review loop resumes.