/** * EJClaw Codex Runner * * App-server only runtime. * * Input protocol: * Stdin: Full RunnerInput JSON (read until EOF) * IPC: Follow-up messages as JSON files in $EJCLAW_IPC_DIR/input/ * Sentinel: _close — signals session end * * Stdout protocol: * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. */ import fs from 'fs'; import path from 'path'; import { EJCLAW_ENV, IPC_CLOSE_SENTINEL, IPC_INPUT_SUBDIR, IPC_POLL_MS, normalizeAgentOutput, writeProtocolOutput, type RunnerStructuredOutput, } from 'ejclaw-runners-shared'; import { CodexAppServerClient } from './app-server-client.js'; import { parseAppServerInput } from './app-server-input.js'; import { prependRoomRoleHeader, type RoomRoleContext, } from './room-role-context.js'; import { assertReadonlyWorkspaceRepoConnectivity, buildReviewerGitGuardEnv, isArbiterRuntimeEnvEnabled, isReviewerRuntime, isReviewerRuntimeEnvEnabled, } from './reviewer-runtime.js'; // ── Types ────────────────────────────────────────────────────────── interface RunnerInput { prompt: string; sessionId?: string; groupFolder: string; chatJid: string; isMain: boolean; isScheduledTask?: boolean; assistantName?: string; agentType?: string; codexGoals?: boolean; roomRoleContext?: RoomRoleContext; } interface RunnerOutput { status: 'success' | 'error'; result: string | null; output?: RunnerStructuredOutput; phase?: 'progress' | 'final'; newSessionId?: string; error?: string; compaction?: { completed: boolean; trigger?: string | null; }; } // ── Constants ────────────────────────────────────────────────────── const GROUP_DIR = process.env[EJCLAW_ENV.groupDir] || '/workspace/group'; const IPC_DIR = process.env[EJCLAW_ENV.ipcDir] || '/workspace/ipc'; const WORK_DIR = process.env[EJCLAW_ENV.workDir] || ''; const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL); const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR; const CODEX_MODEL = process.env.CODEX_MODEL || ''; const CODEX_EFFORT = process.env.CODEX_EFFORT || ''; let closeRequested = false; // ── Helpers ──────────────────────────────────────────────────────── function writeOutput(output: RunnerOutput): void { writeProtocolOutput(output); } function compactionOutput( completed: boolean | undefined, ): Pick { return completed ? { compaction: { completed: true, trigger: null } } : {}; } function normalizeStructuredOutput(result: string | null): { result: string | null; output?: RunnerOutput['output']; } { return normalizeAgentOutput(result); } function log(message: string): void { console.error(`[codex-runner] ${message}`); } function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean { return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true'; } async function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk: string) => { data += chunk; }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); } function consumeCloseSentinel(): boolean { if (closeRequested) return true; if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false; try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } closeRequested = true; return true; } function drainIpcInput(): string[] { try { fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); const files = fs .readdirSync(IPC_INPUT_DIR) .filter((file) => file.endsWith('.json')) .sort(); const messages: string[] = []; for (const file of files) { const filePath = path.join(IPC_INPUT_DIR, file); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); fs.unlinkSync(filePath); if (data.type === 'message' && data.text) { messages.push(data.text); } } catch (err) { log( `Failed to process input file ${file}: ${ err instanceof Error ? err.message : String(err) }`, ); try { fs.unlinkSync(filePath); } catch { /* ignore */ } } } return messages; } catch (err) { log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); return []; } } function formatProgressElapsed(ms: number): string { const elapsedSeconds = Math.floor(ms / 10_000) * 10; const hours = Math.floor(elapsedSeconds / 3600); const minutes = Math.floor((elapsedSeconds % 3600) / 60); const seconds = elapsedSeconds % 60; const parts: string[] = []; if (hours > 0) parts.push(`${hours}시간`); if (minutes > 0) parts.push(`${minutes}분`); parts.push(`${seconds}초`); return parts.join(' '); } async function executeAppServerTurn( client: CodexAppServerClient, threadId: string, prompt: string, retryCount = 0, ): Promise<{ result: string | null; error?: string; compactionCompleted?: boolean; }> { let lastProgressMessage: string | null = null; const activeTurn = await client.startTurn( threadId, parseAppServerInput(prompt, log), { cwd: EFFECTIVE_CWD, model: CODEX_MODEL || undefined, effort: CODEX_EFFORT || undefined, onProgress: (message) => { const trimmed = message.trim(); if (!trimmed || trimmed === lastProgressMessage) { return; } lastProgressMessage = trimmed; writeOutput({ status: 'success', phase: 'progress', ...normalizeStructuredOutput(trimmed), newSessionId: threadId, }); }, }, ); let elapsedMs = 0; let polling = true; const pollDuringTurn = async () => { if (!polling) return; if (consumeCloseSentinel()) { log('Close sentinel detected during app-server turn, interrupting'); polling = false; try { await activeTurn.interrupt(); } catch (err) { log( `Failed to interrupt active turn: ${ err instanceof Error ? err.message : String(err) }`, ); } return; } const messages = drainIpcInput(); if (messages.length > 0) { const merged = messages.join('\n'); log(`Steering active turn with ${messages.length} queued message(s)`); try { await activeTurn.steer(parseAppServerInput(merged, log)); } catch (err) { log( `turn/steer failed: ${err instanceof Error ? err.message : String(err)}`, ); } } elapsedMs += IPC_POLL_MS; if (elapsedMs > 0 && elapsedMs % 60000 === 0) { log(`Turn in progress... (${formatProgressElapsed(elapsedMs)})`); } setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); }; setTimeout(() => void pollDuringTurn(), IPC_POLL_MS); try { const { state, result } = await activeTurn.wait(); if (state.status === 'completed') { return { result, compactionCompleted: state.compactionCompleted }; } if (state.status === 'interrupted' && consumeCloseSentinel()) { return { result, compactionCompleted: state.compactionCompleted }; } if (state.status === 'interrupted' && retryCount < 1) { log('Codex turn interrupted, retrying once...'); return executeAppServerTurn(client, threadId, prompt, retryCount + 1); } return { result, error: state.errorMessage || `Codex turn finished with status ${state.status}`, compactionCompleted: state.compactionCompleted, }; } finally { polling = false; } } async function runAppServerCompact( client: CodexAppServerClient, threadId: string | undefined, ): Promise { if (!threadId) { writeOutput({ status: 'success', result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.', }); return; } const { state } = await client.startCompaction(threadId); if (state.status === 'failed') { writeOutput({ status: 'error', result: null, newSessionId: threadId, error: state.errorMessage || 'Conversation compaction failed.', ...compactionOutput(state.compactionCompleted), }); return; } writeOutput({ status: 'success', result: state.compactionCompleted ? 'Conversation compacted.' : 'Compaction requested but contextCompaction was not observed.', newSessionId: threadId, ...compactionOutput(state.compactionCompleted), }); } async function runAppServerSession( runnerInput: RunnerInput, prompt: string, ): Promise { const reviewerRuntime = isReviewerRuntimeEnvEnabled(process.env) || isReviewerRuntime(runnerInput.roomRoleContext); const readonlyRuntime = reviewerRuntime || isArbiterRuntimeEnvEnabled(process.env); const clientEnv = buildReviewerGitGuardEnv(process.env, reviewerRuntime); assertReadonlyWorkspaceRepoConnectivity(clientEnv, readonlyRuntime); const client = new CodexAppServerClient({ cwd: EFFECTIVE_CWD, env: clientEnv, log, enableGoals: isCodexGoalsEnabled(runnerInput), }); await client.start(); let threadId: string | undefined; try { try { threadId = await client.startOrResumeThread(runnerInput.sessionId, { cwd: EFFECTIVE_CWD, model: CODEX_MODEL || undefined, }); log( runnerInput.sessionId ? `App-server thread resumed (${threadId})` : `App-server thread started (${threadId})`, ); } catch (err) { if (!runnerInput.sessionId) throw err; log( `App-server resume failed, retrying with new thread: ${ err instanceof Error ? err.message : String(err) }`, ); threadId = await client.startOrResumeThread(undefined, { cwd: EFFECTIVE_CWD, model: CODEX_MODEL || undefined, }); log(`App-server thread restarted (${threadId})`); } const trimmedPrompt = prompt.trim(); if (trimmedPrompt === '/compact') { await runAppServerCompact(client, threadId); return; } log('Starting app-server turn...'); const { result, error, compactionCompleted } = await executeAppServerTurn( client, threadId, prompt, ); if (error) { const normalized = normalizeStructuredOutput(result || null); log(`App-server turn error: ${error}`); writeOutput({ status: 'error', ...normalized, newSessionId: threadId, error, ...compactionOutput(compactionCompleted), }); } else { const normalized = normalizeStructuredOutput(result || null); writeOutput({ status: 'success', ...normalized, ...(result ? { phase: 'final' as const } : {}), newSessionId: threadId, ...compactionOutput(compactionCompleted), }); } if (consumeCloseSentinel()) { log('Close sentinel detected, exiting app-server runtime'); } } finally { await client.close(); } } // ── Main ────────────────────────────────────────────────────────── async function main(): Promise { let runnerInput: RunnerInput; try { const stdinData = await readStdin(); runnerInput = JSON.parse(stdinData); try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } log(`Received input for group: ${runnerInput.groupFolder}`); } catch (err) { writeOutput({ status: 'error', result: null, error: `Failed to parse input: ${ err instanceof Error ? err.message : String(err) }`, }); process.exit(1); } fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); closeRequested = false; try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } const rawPrompt = runnerInput.prompt; let prompt = rawPrompt; if (runnerInput.isScheduledTask) { prompt = `[SCHEDULED TASK]\n\n${prompt}`; } const pending = drainIpcInput(); if (pending.length > 0) { prompt += '\n' + pending.join('\n'); } // Prepend room role header AFTER checking for session commands, // so /compact is not masked by the header prefix. const isSessionCommand = rawPrompt.trim() === '/compact'; if (!isSessionCommand) { prompt = prependRoomRoleHeader(prompt, runnerInput.roomRoleContext); } try { log('Runtime selected: app-server'); await runAppServerSession(runnerInput, prompt); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); log(`Runner error: ${errorMessage}`); writeOutput({ status: 'error', result: null, error: errorMessage, }); } } main();