diff --git a/container/Dockerfile b/container/Dockerfile index 6ef6f21..f6ee1ae 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -31,6 +31,10 @@ RUN apt-get update --fix-missing && apt-get install -y --no-install-recommends \ zip \ unzip \ strace \ + ripgrep \ + fd-find \ + bat \ + tree \ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ @@ -52,18 +56,24 @@ RUN curl -fsSL https://bun.sh/install | bash # Create app directory WORKDIR /app -# Copy host's agent-runner (same code, same SDK version) -COPY runners/agent-runner/package*.json ./ -RUN bun install -COPY runners/agent-runner/ ./ -RUN bun run build +# Copy agent-runner (Claude Code) +COPY runners/agent-runner/package*.json ./agent/ +RUN bun install --cwd ./agent +COPY runners/agent-runner/ ./agent/ +RUN bun run --cwd ./agent build + +# Copy codex-runner +COPY runners/codex-runner/package*.json ./codex/ +RUN bun install --cwd ./codex +COPY runners/codex-runner/ ./codex/ +RUN bun run --cwd ./codex build # Create workspace directories RUN mkdir -p /workspace/project /workspace/group /workspace/global \ /workspace/ipc/messages /workspace/ipc/input -# Create entrypoint script -RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nbun /app/dist/index.js < /tmp/input.json\n' \ +# Entrypoint (fallback — persistent containers use docker exec with explicit runner path) +RUN printf '#!/bin/bash\nset -e\ncat > /tmp/input.json\nbun /app/agent/dist/index.js < /tmp/input.json\n' \ > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a953b08 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,106 @@ +# Architecture + +## Service Stack + +Single unified service (`ejclaw`) manages all three Discord bots in one process: + +- `ejclaw.service` — Single unified process +- Discord bots: `DISCORD_BOT_TOKEN` (Claude), `DISCORD_CODEX_BOT_TOKEN` (Codex-main), `DISCORD_REVIEW_BOT_TOKEN` (Codex-review) +- Paired review: owner ↔ reviewer (agent types configurable per role) +- Reviewer fallback: Claude exhaustion → codex-review auto-handoff +- Shared dirs: `store/`, `groups/`, `data/` +- SQLite WAL mode + `busy_timeout=5000` for concurrent access + +## Data Flow + +``` +Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (host process) + │ │ + │ ▼ (auto-trigger) + ├──► Reviewer (Docker container, :ro mount) + │ │ + │ Verdict routing + │ ├─ DONE → change detection → finalize or re-review + │ ├─ BLOCKED → Arbiter (if enabled) or User + │ └─ Feedback → Owner (loop) + │ + ├──► Arbiter (on-demand, fresh session each time) + │ │ + │ ┌───┴─── MoA (if enabled) ───┐ + │ │ Ref model A ──► opinion │ + │ │ Ref model B ──► opinion │ + │ │ → injected into prompt │ + │ └────────────────────────────┘ + │ │ + │ PROCEED/REVISE/RESET/ESCALATE + │ + IPC polling ◄── follow-up messages + │ + ┌────────── Router ──────────┐ + ▼ ▼ + paired_turn_outputs Discord (display only) + (agent ↔ agent data) (user observation, @mention) +``` + +## Room Assignment Model + +Per-room routing uses an explicit assignment model: + +- `room_settings` is the room-level source of truth (SSOT) +- Each room stores: + - `room_mode`: `single` or `tribunal` + - `owner_agent_type`: `codex` or `claude-code` +- Public room assignment uses `assign_room` +- `registered_groups` remains as a materialized capability/read-model layer + +Operationally: + +- `single` → one owner bot +- `tribunal` → per-room owner + globally configured reviewer + optional arbiter + +Tribunal is no longer inferred from "two bots registered on one room"; it is an explicit room setting. + +## Tribunal 3-Agent System + +``` +User message + → Owner responds (implementation, answer, etc.) + → Reviewer auto-triggered (critical review, design check) + → Verdict: + DONE → Owner finalizes → Task completed → @user ✅ + DONE_WITH_CONCERNS → Owner addresses feedback → loop + BLOCKED/NEEDS_CONTEXT + ├─ Arbiter enabled → Arbiter judges → PROCEED/REVISE/RESET/ESCALATE + └─ Arbiter disabled → Escalate to user → @user ⚠️ + → Owner BLOCKED/NEEDS_CONTEXT → Arbiter (same path as reviewer) + → Deadlock (3+ round trips without progress) + → Arbiter summoned → binding verdict → loop resumes +``` + +## Container Isolation + +Reviewer and arbiter run inside a Docker container with: + +- Read-only source mount (kernel-level write protection) +- Both Claude (agent-runner) and Codex (codex-runner) supported +- Runner selected at `docker exec` time based on agent type +- tmpfs overlays for test runner caches +- IPC via filesystem for follow-up messages +- Credentials injected per-exec (never baked into container) + +## Key Files + +| File | Purpose | +|------|---------| +| `src/index.ts` | Orchestrator: state, message loop, agent invocation | +| `src/agent-runner.ts` | Spawns agent processes, manages env/sessions/skills | +| `src/container-runner.ts` | Docker container execution for reviewer/arbiter | +| `src/channels/discord.ts` | Discord channel (8s typing refresh, Whisper transcription) | +| `src/ipc.ts` | IPC watcher and task processing | +| `src/router.ts` | Message formatting and outbound routing | +| `src/config.ts` | Trigger pattern, paths, intervals | +| `src/task-scheduler.ts` | Runs scheduled tasks | +| `src/db.ts` | SQLite operations | +| `runners/agent-runner/` | Claude Code runner (Agent SDK) | +| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) | +| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..349e86e --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,83 @@ +# Configuration + +All configuration in a single `.env` file. + +## Discord Bots + +```bash +DISCORD_BOT_TOKEN= # Claude bot +DISCORD_CODEX_BOT_TOKEN= # Codex-main bot (owner) +DISCORD_REVIEW_BOT_TOKEN= # Codex-review bot (arbiter) +``` + +## Agent Types & Models + +```bash +# Agent types per role +OWNER_AGENT_TYPE=codex # codex | claude-code +REVIEWER_AGENT_TYPE=claude-code # claude-code | codex +ARBITER_AGENT_TYPE=codex # codex | claude-code (optional, enables 3rd agent) + +# Per-role model overrides +OWNER_MODEL=gpt-5.4 +REVIEWER_MODEL=claude-opus-4-6 +ARBITER_MODEL=gpt-5.4 + +# Per-role effort level +OWNER_EFFORT=xhigh +REVIEWER_EFFORT=high +ARBITER_EFFORT=xhigh + +# Per-role fallback toggle +REVIEWER_FALLBACK_ENABLED=true # Auto-handoff to codex when Claude exhausted +ARBITER_FALLBACK_ENABLED=false + +# Response language +AGENT_LANGUAGE=Korean # Injected into all agent prompts +``` + +## Authentication + +```bash +CLAUDE_CODE_OAUTH_TOKEN= # Claude Code OAuth token +CLAUDE_CODE_OAUTH_TOKENS= # Comma-separated for multi-account rotation +OPENAI_API_KEY= # Codex API key (if not using OAuth) +``` + +## Voice Transcription + +```bash +GROQ_API_KEY= # Groq whisper-large-v3-turbo (primary) +OPENAI_API_KEY= # OpenAI whisper-1 (fallback) +``` + +## Mixture of Agents (MoA) + +```bash +MOA_ENABLED=true +MOA_REF_MODELS=kimi,glm # Configurable reference model list + +# Per-model config (pattern: MOA_{NAME}_{SETTING}) +MOA_KIMI_MODEL=kimi-k2.5 +MOA_KIMI_BASE_URL=https://api.kimi.com/coding +MOA_KIMI_API_KEY=sk-kimi-xxx +MOA_KIMI_API_FORMAT=anthropic + +MOA_GLM_MODEL=glm-5.1 +MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic +MOA_GLM_API_KEY=xxx +MOA_GLM_API_FORMAT=anthropic +``` + +## Debugging Paths + +| Item | Path | +|------|------| +| **DB** | `store/messages.db` (shared, WAL mode) | +| Service log | `journalctl --user -u ejclaw -f` or `logs/ejclaw.log` | +| Per-group logs | `groups/{name}/logs/` | +| Claude sessions | `data/sessions/{name}/.claude/` | +| Codex sessions | `data/sessions/{name}/.codex/` | +| Claude platform rules | `prompts/claude-platform.md` | +| Codex platform rules | `prompts/codex-platform.md` | +| Global memory | `groups/global/CLAUDE.md` | diff --git a/prompts/arbiter-paired-room.md b/prompts/arbiter-paired-room.md index d0803e0..48af0bd 100644 --- a/prompts/arbiter-paired-room.md +++ b/prompts/arbiter-paired-room.md @@ -22,6 +22,15 @@ You have been summoned because the owner and reviewer reached a deadlock after m - The situation cannot be resolved without user input, regardless of technical agreement - The same NEEDS_CONTEXT or BLOCKED is repeated after a prior PROCEED — this means your PROCEED did not resolve the issue +## MoA (Mixture of Agents) Reference Opinions + +You may receive reference opinions from external models appended to your prompt. When present: + +- **Cite them explicitly** in your verdict — e.g., "Reference model A agrees that...", "Reference model B raises a concern about..." +- **Cross-reference** their opinions against the owner/reviewer conversation and code evidence +- **Resolve conflicts** — if reference opinions disagree with each other or with owner/reviewer, state which view you adopt and why +- Do NOT blindly follow reference opinions — they are inputs to your judgment, not authorities + ## Rules - Base your verdict on evidence (code, test output, logs), not on who said what first diff --git a/src/agent-error-detection.ts b/src/agent-error-detection.ts index c743cbd..e4e2af9 100644 --- a/src/agent-error-detection.ts +++ b/src/agent-error-detection.ts @@ -102,7 +102,8 @@ export type AgentTriggerReason = | 'session-failure' | 'overloaded' | 'network-error' - | 'success-null-result'; + | 'success-null-result' + | 'session-failure'; export type ClaudeRotationReason = Extract< AgentTriggerReason, diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 76d5d5c..174a12b 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -97,6 +97,26 @@ export async function runAgentProcess( memoryBriefing: input.memoryBriefing, role: containerRole, }); + // For codex: also write AGENTS.md to the reviewer session dir, because + // the container's /home/node/.claude always mounts the reviewer session. + // Arbiter and reviewer never run simultaneously, so this is safe. + if (containerRole === 'arbiter') { + const reviewerSessionDir = path.join( + path.dirname(sessionDir), + `${group.folder}-reviewer`, + ); + const reviewerCodexDir = path.join(reviewerSessionDir, '.codex'); + if (fs.existsSync(reviewerSessionDir)) { + fs.mkdirSync(reviewerCodexDir, { recursive: true }); + const arbiterAgentsMd = path.join(sessionDir, '.codex', 'AGENTS.md'); + if (fs.existsSync(arbiterAgentsMd)) { + fs.copyFileSync( + arbiterAgentsMd, + path.join(reviewerCodexDir, 'AGENTS.md'), + ); + } + } + } } return runReviewerContainer({ diff --git a/src/container-runner.ts b/src/container-runner.ts index de25084..b81b0db 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -321,6 +321,28 @@ export function buildReviewerMounts( readonly: false, }); + // Owner session directory: read-only so reviewer can verify runtime state + // files (cron state, configs, etc.) that the owner references by absolute path. + const ownerSessionDir = path.join(DATA_DIR, 'sessions', group.folder); + if (fs.existsSync(ownerSessionDir)) { + mounts.push({ + hostPath: ownerSessionDir, + containerPath: ownerSessionDir, + readonly: true, + }); + } + + // Codex OAuth: mount host's ~/.codex (read-only) so codex-runner can authenticate + const hostCodexHome = + process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + if (fs.existsSync(hostCodexHome)) { + mounts.push({ + hostPath: hostCodexHome, + containerPath: '/home/node/.codex', + readonly: true, + }); + } + return mounts; } @@ -367,12 +389,20 @@ function buildCreateArgs( args.push('-e', `SENTRY_AUTH_TOKEN=${process.env.SENTRY_AUTH_TOKEN}`); } - // Extra env overrides from paired-execution-context + // Extra env overrides from paired-execution-context. + // Model/effort keys are excluded here — they are injected per-exec so the + // correct agent-type-specific values are used (claude vs codex). + const perExecKeys = new Set([ + 'CLAUDE_MODEL', + 'CLAUDE_EFFORT', + 'CODEX_MODEL', + 'CODEX_EFFORT', + ]); if (envOverrides) { for (const [key, value] of Object.entries(envOverrides)) { - // Already set above — skip duplicates if (key === 'ANTHROPIC_API_KEY' || key === 'CLAUDE_CODE_OAUTH_TOKEN') continue; + if (perExecKeys.has(key)) continue; if (key === 'EJCLAW_WORK_DIR') { args.push('-e', 'EJCLAW_WORK_DIR=/workspace/project'); continue; @@ -469,22 +499,38 @@ export async function runReviewerContainer(args: { ''; execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } - // Inject model/effort overrides at exec-time so per-role config - // takes effect even with persistent containers. - const modelEnvKeys = [ - 'CLAUDE_MODEL', - 'CLAUDE_EFFORT', - 'CODEX_MODEL', - 'CODEX_EFFORT', - ]; + const isCodexAgent = (group.agentType || 'claude-code') === 'codex'; + logger.info( + { + containerName, + groupAgentType: group.agentType, + isCodexAgent, + runnerPath: isCodexAgent + ? '/app/codex/dist/index.js' + : '/app/agent/dist/index.js', + }, + 'Container exec runner selection', + ); + // Inject only agent-type-appropriate model/effort overrides per exec. if (envOverrides) { + const modelEnvKeys = isCodexAgent + ? ['CODEX_MODEL', 'CODEX_EFFORT'] + : ['CLAUDE_MODEL', 'CLAUDE_EFFORT']; for (const key of modelEnvKeys) { if (envOverrides[key]) { execArgs.push('-e', `${key}=${envOverrides[key]}`); } } } - execArgs.push(containerName, 'bun', '/app/dist/index.js'); + if (isCodexAgent) { + // Use session-local .codex dir (contains AGENTS.md with role prompts) + // instead of the host-mounted ~/.codex (which has owner-only config). + execArgs.push('-e', 'CODEX_HOME=/home/node/.claude/.codex'); + } + const runnerPath = isCodexAgent + ? '/app/codex/dist/index.js' + : '/app/agent/dist/index.js'; + execArgs.push(containerName, 'bun', runnerPath); return new Promise((resolve) => { const proc = spawn(CONTAINER_RUNTIME_BIN, execArgs, { diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 75970fb..d2c77b6 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -591,6 +591,7 @@ function buildModelConfigSection(): string { }, ]; + const failover = getGlobalFailoverInfo(); const lines = ['🤖 *모델 구성*']; for (const role of roleConfigs) { if (!role.agentType && role.label === 'Arbiter') continue; @@ -600,7 +601,15 @@ function buildModelConfigSection(): string { ? process.env.CODEX_MODEL || 'codex' : process.env.CLAUDE_MODEL || 'claude'; const model = role.model || defaultModel; - lines.push(` **${role.label}** — ${type} \`${model}\``); + + // Show fallback status for claude-code roles when global failover is active + const isFallback = failover.active && type === 'claude-code'; + if (isFallback) { + const fallbackModel = process.env.CODEX_MODEL || 'codex'; + lines.push(` **${role.label}** — codex \`${fallbackModel}\` (fallback)`); + } else { + lines.push(` **${role.label}** — ${type} \`${model}\``); + } } // MoA status @@ -612,8 +621,6 @@ function buildModelConfigSection(): string { lines.push(` **MoA** — ${refs}`); } - // Global failover - const failover = getGlobalFailoverInfo(); if (failover.active) { lines.push(` ⚠️ **Failover 활성** — ${failover.reason || '알 수 없음'}`); }