diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 4dc06c1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,100 +0,0 @@ -# EJClaw - -Dual-agent AI assistant (Claude Code + Codex) over Discord. Originally derived from [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw). - -## Quick Context - -Single unified service (`ejclaw`) manages three Discord bots (owner, reviewer, arbiter) in one process. Agent types behind each role remain configurable via `OWNER_AGENT_TYPE`, `REVIEWER_AGENT_TYPE`, and `ARBITER_AGENT_TYPE` in `.env`. Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`). - -## 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/channels/discord.ts` | Discord channel (8s typing refresh, Groq/OpenAI 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) | - -## Skills - -| Skill | When to Use | -|-------|-------------| -| `/setup` | First-time installation, authentication, service configuration | -| `/customize` | Adding channels, integrations, changing behavior | -| `/debug` | Agent issues, logs, troubleshooting | - -## Development - -Run commands directly—don't tell the user to run them. - -```bash -bun run build # Build main project -bun run build:runners # Install + build both runners -bun run build:runtime # Build host runtime only -bun run dev # Dev mode with hot reload -``` - -Service management (Linux): -```bash -systemctl --user restart ejclaw # Restart unified service -systemctl --user status ejclaw # Check status -journalctl --user -u ejclaw -f # Follow logs -``` - -Deploy: -```bash -bun run deploy -``` - -`deploy` rebuilds only the host runtime, and verification also runs directly on -the host runtime. - -## Service Stack Architecture - -Single unified service manages all three Discord bots in one process: -- `ejclaw.service` — Single unified process -- Discord bots: `DISCORD_OWNER_BOT_TOKEN` (owner), `DISCORD_REVIEWER_BOT_TOKEN` (reviewer), `DISCORD_ARBITER_BOT_TOKEN` (arbiter) -- Old service-based token names are no longer accepted; migrate them to the canonical role-based keys above before starting the service -- Paired review: owner (`OWNER_AGENT_TYPE`, default: codex) ↔ reviewer (`REVIEWER_AGENT_TYPE`, default: claude-code) -- Provider fallback is internal routing only — visible Discord bots stay owner/reviewer/arbiter fixed -- Shared dirs: `store/`, `groups/`, `data/` -- SQLite WAL mode + `busy_timeout=5000` for concurrent access - -## Debugging Paths - -Unified DB + directories (both services share `store/`, `groups/`, `data/`): - -| 항목 | 경로 | -|------|------| -| **DB** | `store/messages.db` (공유, WAL 모드) | -| 서비스 로그 | `journalctl --user -u ejclaw -f` 또는 `logs/ejclaw.log` | -| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) | -| Claude 세션 | `data/sessions/{name}/.claude/` | -| Codex 세션 | `data/sessions/{name}/.codex/` | -| Claude 플랫폼 규칙 | `prompts/claude-platform.md` | -| Codex 플랫폼 규칙 | `prompts/codex-platform.md` | -| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` | - -## Codex SDK - -Codex runner uses `@openai/codex-sdk` (wraps `codex exec`): -- `codex.startThread()` / `codex.resumeThread()` for session persistence -- `thread.run(input)` for single-shot turn execution (completes all work before returning) -- `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` for bypass -- Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml` -- `CODEX_HOME` set to per-group session dir, reads `AGENTS.md` from there + CWD - -## Voice Transcription - -Audio attachments in Discord are transcribed via Groq Whisper (primary) or OpenAI Whisper (fallback): -- `GROQ_API_KEY` — Groq `whisper-large-v3-turbo`, ~200x real-time, free tier (console.groq.com) -- `OPENAI_API_KEY` — OpenAI `whisper-1`, fallback if Groq key not set -- Shared file cache (`cache/transcriptions/`) deduplicates across both services -- `.pending` file coordination prevents duplicate API calls diff --git a/runners/agent-runner/src/ipc-mcp-stdio.ts b/runners/agent-runner/src/ipc-mcp-stdio.ts index f45627b..2bff913 100644 --- a/runners/agent-runner/src/ipc-mcp-stdio.ts +++ b/runners/agent-runner/src/ipc-mcp-stdio.ts @@ -25,9 +25,8 @@ import { import { computeVerificationSnapshotId, formatVerificationResponse, - resolveVerificationResponsesDir, + runVerificationRequestDirect, VERIFICATION_PROFILES, - waitForVerificationResponse, } from './verification.js'; import { resolveIpcDirectories } from './ipc-paths.js'; import { buildSendMessageIpcPayload } from './ipc-message.js'; @@ -38,8 +37,6 @@ const MESSAGES_DIR = path.join(HOST_IPC_DIR, 'messages'); const TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks'); const HOST_EVIDENCE_RESPONSES_DIR = resolveHostEvidenceResponsesDir(HOST_IPC_DIR); -const VERIFICATION_RESPONSES_DIR = - resolveVerificationResponsesDir(HOST_IPC_DIR); const REPO_ROOT = process.env.EJCLAW_WORK_DIR || process.cwd(); // Context from environment variables (set by the agent runner) @@ -453,20 +450,12 @@ server.tool( .toString(36) .slice(2, 8)}`; - writeIpcFile(TASKS_DIR, { - type: 'verification_request', - requestId, - profile: args.profile, - expected_snapshot_id: snapshotId, - groupFolder, - timestamp: new Date().toISOString(), - }); - try { - const response = await waitForVerificationResponse( - VERIFICATION_RESPONSES_DIR, + const response = await runVerificationRequestDirect(REPO_ROOT, { requestId, - ); + profile: args.profile, + expectedSnapshotId: snapshotId, + }); return { content: [ { diff --git a/runners/agent-runner/src/verification.ts b/runners/agent-runner/src/verification.ts index b274942..09d14ae 100644 --- a/runners/agent-runner/src/verification.ts +++ b/runners/agent-runner/src/verification.ts @@ -1,8 +1,8 @@ -import fs from 'fs'; +import { execFile } from 'child_process'; import path from 'path'; +import { pathToFileURL } from 'url'; export { computeVerificationSnapshotId, - resolveVerificationResponsesDir, } from '../../../shared/verification-snapshot.js'; export const VERIFICATION_PROFILES = [ @@ -13,6 +13,45 @@ export const VERIFICATION_PROFILES = [ export type VerificationProfile = (typeof VERIFICATION_PROFILES)[number]; +type VerificationHelperEnvKey = + | 'PATH' + | 'HOME' + | 'USER' + | 'LOGNAME' + | 'SHELL' + | 'LANG' + | 'LC_ALL' + | 'LC_CTYPE' + | 'NODE_PATH' + | 'NODE_OPTIONS' + | 'TERM' + | 'COLORTERM' + | 'FORCE_COLOR'; + +const VERIFICATION_HELPER_ENV_KEYS: readonly VerificationHelperEnvKey[] = [ + 'PATH', + 'HOME', + 'USER', + 'LOGNAME', + 'SHELL', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'NODE_PATH', + 'NODE_OPTIONS', + 'TERM', + 'COLORTERM', + 'FORCE_COLOR', +]; +const HELPER_TIMEOUT_MS = 20 * 60 * 1000; +const HELPER_MAX_BUFFER = 20 * 1024 * 1024; + +export interface VerificationRequest { + requestId: string; + profile: VerificationProfile; + expectedSnapshotId?: string; +} + export interface VerificationResponse { requestId: string; ok: boolean; @@ -36,33 +75,87 @@ export function isVerificationProfile( ); } -export async function waitForVerificationResponse( - responseDir: string, - requestId: string, - options?: { - timeoutMs?: number; - pollMs?: number; - }, -): Promise { - const timeoutMs = options?.timeoutMs ?? 20 * 60 * 1000; - const pollMs = options?.pollMs ?? 100; - const responsePath = path.join(responseDir, `${requestId}.json`); - const deadline = Date.now() + timeoutMs; +function buildVerificationHelperEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + TZ: 'Asia/Seoul', + CI: '1', + }; - while (Date.now() <= deadline) { - if (fs.existsSync(responsePath)) { - const response = JSON.parse( - fs.readFileSync(responsePath, 'utf-8'), - ) as VerificationResponse; - fs.unlinkSync(responsePath); - return response; + for (const key of VERIFICATION_HELPER_ENV_KEYS) { + const value = process.env[key]; + if (value) { + env[key] = value; } - await new Promise((resolve) => setTimeout(resolve, pollMs)); } - throw new Error( - `Timed out waiting for verification response: ${requestId}`, + return env; +} + +function execFileCapture( + file: string, + args: string[], + options?: { + cwd?: string; + env?: NodeJS.ProcessEnv; + }, +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + encoding: 'utf8', + timeout: HELPER_TIMEOUT_MS, + maxBuffer: HELPER_MAX_BUFFER, + cwd: options?.cwd, + env: options?.env, + }, + (error, stdout, stderr) => { + if (error) { + reject( + Object.assign(error, { + stdout, + stderr, + }), + ); + return; + } + + resolve({ stdout, stderr }); + }, + ); + }); +} + +export async function runVerificationRequestDirect( + repoRoot: string, + request: VerificationRequest, +): Promise { + const helperPath = path.join( + repoRoot, + 'shared', + 'verification-request-runner.js', ); + const helperEnv = buildVerificationHelperEnv(); + const { stdout, stderr } = await execFileCapture( + 'bun', + [helperPath, repoRoot, JSON.stringify(request)], + { + cwd: repoRoot, + env: helperEnv, + }, + ); + + try { + return JSON.parse(stdout) as VerificationResponse; + } catch (error) { + const detail = stderr.trim() || stdout.trim(); + throw new Error( + `Failed to parse verification response from ${pathToFileURL(helperPath).href}: ${ + error instanceof Error ? error.message : String(error) + }${detail ? `\n${detail}` : ''}`, + ); + } } export function formatVerificationResponse( diff --git a/runners/agent-runner/test/verification.test.ts b/runners/agent-runner/test/verification.test.ts index dbd15c5..85a08eb 100644 --- a/runners/agent-runner/test/verification.test.ts +++ b/runners/agent-runner/test/verification.test.ts @@ -7,8 +7,6 @@ import { describe, expect, it } from 'vitest'; import { computeVerificationSnapshotId, formatVerificationResponse, - resolveVerificationResponsesDir, - waitForVerificationResponse, } from '../src/verification.js'; describe('runner verification helpers', () => { @@ -26,39 +24,6 @@ describe('runner verification helpers', () => { expect(second).toBe(first); }); - it('reads and removes the verification response file', async () => { - const ipcDir = fs.mkdtempSync( - path.join(os.tmpdir(), 'ejclaw-verification-ipc-'), - ); - const responseDir = resolveVerificationResponsesDir(ipcDir); - fs.mkdirSync(responseDir, { recursive: true }); - - const responsePath = path.join(responseDir, 'req-1.json'); - fs.writeFileSync( - responsePath, - JSON.stringify({ - requestId: 'req-1', - ok: true, - profile: 'typecheck', - command: 'npm run typecheck', - stdout: '', - stderr: '', - exitCode: 0, - snapshotId: 'fs:abc123', - runtimeVersion: 'host:bun@test', - }), - ); - - const response = await waitForVerificationResponse(responseDir, 'req-1', { - timeoutMs: 100, - pollMs: 10, - }); - - expect(response.ok).toBe(true); - expect(response.snapshotId).toBe('fs:abc123'); - expect(fs.existsSync(responsePath)).toBe(false); - }); - it('formats the response into a compact MCP-friendly text block', () => { const text = formatVerificationResponse({ requestId: 'req-2', diff --git a/shared/verification-request-runner.js b/shared/verification-request-runner.js new file mode 100644 index 0000000..c6e34d0 --- /dev/null +++ b/shared/verification-request-runner.js @@ -0,0 +1,35 @@ +import path from 'path'; +import { pathToFileURL } from 'url'; + +async function main() { + const [repoDir, requestJson] = process.argv.slice(2); + + if (!repoDir || !requestJson) { + throw new Error( + 'usage: bun shared/verification-request-runner.js ', + ); + } + + const verificationModuleUrl = pathToFileURL( + path.join(repoDir, 'src', 'verification.ts'), + ).href; + const verificationModule = await import(verificationModuleUrl); + const request = JSON.parse(requestJson); + const result = await verificationModule.runVerificationRequest(request, { + repoDir, + }); + + process.stdout.write( + JSON.stringify({ + requestId: request.requestId, + ...result, + }), + ); +} + +main().catch((error) => { + const message = + error instanceof Error ? error.stack || error.message : String(error); + process.stderr.write(message); + process.exit(1); +}); diff --git a/src/ipc.ts b/src/ipc.ts index 2e6b567..4c25b49 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -9,11 +9,6 @@ import { runHostEvidenceRequest, writeHostEvidenceResponse, } from './host-evidence.js'; -import { - isVerificationProfile, - runVerificationRequest, - writeVerificationResponse, -} from './verification.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { @@ -642,65 +637,6 @@ export async function processTaskIpc( } break; - case 'verification_request': - if (!data.requestId) { - logger.warn( - { sourceGroup }, - 'Ignoring verification_request without requestId', - ); - break; - } - - if (!isVerificationProfile(data.profile)) { - writeVerificationResponse(sourceGroup, { - requestId: data.requestId, - ok: false, - profile: 'test', - command: '', - stdout: '', - stderr: '', - exitCode: 1, - snapshotId: 'unknown', - runtimeVersion: '', - workdir: process.cwd(), - error: `Unsupported verification profile: ${String(data.profile)}`, - }); - logger.warn( - { sourceGroup, requestId: data.requestId, profile: data.profile }, - 'Rejected unsupported verification profile', - ); - break; - } - - { - const result = await runVerificationRequest({ - requestId: data.requestId, - profile: data.profile, - expectedSnapshotId: - typeof data.expected_snapshot_id === 'string' - ? data.expected_snapshot_id - : undefined, - }); - - writeVerificationResponse(sourceGroup, { - requestId: data.requestId, - ...result, - }); - - logger.info( - { - sourceGroup, - requestId: data.requestId, - profile: data.profile, - ok: result.ok, - exitCode: result.exitCode, - snapshotId: result.snapshotId, - }, - 'Processed verification request via IPC', - ); - } - break; - case 'update_task': if (data.taskId) { const task = getTaskById(data.taskId); diff --git a/src/verification-ipc.test.ts b/src/verification-ipc.test.ts deleted file mode 100644 index 2041ab9..0000000 --- a/src/verification-ipc.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { runVerificationRequestMock } = vi.hoisted(() => ({ - runVerificationRequestMock: vi.fn(), -})); - -vi.mock('./verification.js', async () => { - const actual = - await vi.importActual( - './verification.js', - ); - return { - ...actual, - runVerificationRequest: runVerificationRequestMock, - }; -}); - -import { _initTestDatabase, _setRegisteredGroupForTests } from './db.js'; -import { resolveGroupIpcPath } from './group-folder.js'; -import { processTaskIpc, type IpcDeps } from './ipc.js'; -import type { RegisteredGroup } from './types.js'; - -const VERIFICATION_GROUP: RegisteredGroup = { - name: 'Verification', - folder: 'verification-group', - trigger: '@Andy', - added_at: '2024-01-01T00:00:00.000Z', -}; - -describe('verification IPC', () => { - let deps: IpcDeps; - - beforeEach(() => { - _initTestDatabase(); - _setRegisteredGroupForTests('verification@g.us', VERIFICATION_GROUP); - runVerificationRequestMock.mockReset(); - - deps = { - sendMessage: async () => {}, - registeredGroups: () => ({ 'verification@g.us': VERIFICATION_GROUP }), - assignRoom: () => {}, - syncGroups: async () => {}, - getAvailableGroups: () => [], - writeGroupsSnapshot: () => {}, - }; - - fs.rmSync(resolveGroupIpcPath('verification-group'), { - recursive: true, - force: true, - }); - }); - - it('writes verification responses into the source group namespace', async () => { - runVerificationRequestMock.mockResolvedValue({ - ok: true, - profile: 'typecheck', - command: 'npm run typecheck', - stdout: '', - stderr: '', - exitCode: 0, - snapshotId: 'fs:abc123', - runtimeVersion: 'host:bun@test', - }); - - await processTaskIpc( - { - type: 'verification_request', - requestId: 'req-1', - profile: 'typecheck', - expected_snapshot_id: 'fs:abc123', - }, - 'verification-group', - false, - deps, - ); - - const responsePath = path.join( - resolveGroupIpcPath('verification-group'), - 'verification-responses', - 'req-1.json', - ); - const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { - ok: boolean; - requestId: string; - snapshotId: string; - }; - - expect(runVerificationRequestMock).toHaveBeenCalledWith({ - requestId: 'req-1', - profile: 'typecheck', - expectedSnapshotId: 'fs:abc123', - }); - expect(response.requestId).toBe('req-1'); - expect(response.ok).toBe(true); - expect(response.snapshotId).toBe('fs:abc123'); - }); - - it('returns an error response for unsupported profiles without executing', async () => { - await processTaskIpc( - { - type: 'verification_request', - requestId: 'req-2', - profile: 'rm -rf /', - }, - 'verification-group', - false, - deps, - ); - - const responsePath = path.join( - resolveGroupIpcPath('verification-group'), - 'verification-responses', - 'req-2.json', - ); - const response = JSON.parse(fs.readFileSync(responsePath, 'utf-8')) as { - ok: boolean; - error?: string; - }; - - expect(runVerificationRequestMock).not.toHaveBeenCalled(); - expect(response.ok).toBe(false); - expect(response.error).toContain('Unsupported verification profile'); - }); -}); diff --git a/src/verification.ts b/src/verification.ts index 8eebd4b..787502d 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -4,16 +4,12 @@ import os from 'os'; import path from 'path'; import { TIMEZONE } from './config.js'; -import { resolveGroupIpcPath } from './group-folder.js'; import { buildWorkspaceScriptCommand, ensureWorkspaceDependenciesInstalled, hasInstalledNodeModules, } from './workspace-package-manager.js'; -import { - computeVerificationSnapshotId, - resolveVerificationResponsesDir, -} from '../shared/verification-snapshot.js'; +import { computeVerificationSnapshotId } from '../shared/verification-snapshot.js'; export const VERIFICATION_PROFILES = ['test', 'typecheck', 'build'] as const; @@ -38,10 +34,6 @@ export interface VerificationResult { error?: string; } -export interface VerificationResponse extends VerificationResult { - requestId: string; -} - export interface VerificationSnapshot { snapshotId: string; } @@ -393,21 +385,3 @@ export async function runVerificationRequest( fs.rmSync(directExecution.tempRoot, { recursive: true, force: true }); } } - -export function resolveVerificationResponseDir(groupFolder: string): string { - return resolveVerificationResponsesDir(resolveGroupIpcPath(groupFolder)); -} - -export function writeVerificationResponse( - groupFolder: string, - response: VerificationResponse, -): string { - const responseDir = resolveVerificationResponseDir(groupFolder); - fs.mkdirSync(responseDir, { recursive: true }); - - const outputPath = path.join(responseDir, `${response.requestId}.json`); - const tempPath = `${outputPath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(response, null, 2)); - fs.renameSync(tempPath, outputPath); - return outputPath; -}