codexmobile-relay / server /codex-runner.js
Codex
deploy: CodexMobile Relay
90f0300
Raw
History Blame Contribute Delete
8.99 kB
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { buildCodexLarkCliContext } from './lark-cli.js';
import { emitCodexEvent, emitStatus } from './codex-runner-events.js';
import { detectFeishuSkillKeys } from './feishu-skills.js';
const activeRuns = new Map();
const NON_ASCII_PATH_PATTERN = /[^\u0000-\u007F]/;
const CODEXMOBILE_REPLY_INSTRUCTION = [
'CodexMobile iOS/PWA 回复要求:最终回复必须简短、直接。',
'除非用户明确要求,最终只写结果、关键链接、必要下一步;不要复述内部过程、命令日志或长篇验证细节。'
].join('\n');
async function ensureAsciiWorkingDirectory(projectPath) {
if (process.platform !== 'win32' || !NON_ASCII_PATH_PATTERN.test(projectPath)) {
return projectPath;
}
const resolved = path.resolve(projectPath);
const driveRoot = path.parse(resolved).root || 'C:\\';
const aliasRoot = path.join(driveRoot, 'codex_project_aliases');
const aliasName = crypto.createHash('sha1').update(resolved.toLowerCase()).digest('hex');
const aliasPath = path.join(aliasRoot, aliasName);
await fs.mkdir(aliasRoot, { recursive: true });
try {
const stats = await fs.lstat(aliasPath);
if (stats.isDirectory() || stats.isSymbolicLink()) {
return aliasPath;
}
await fs.rm(aliasPath, { recursive: true, force: true });
} catch (error) {
if (error.code !== 'ENOENT') {
throw error;
}
}
await fs.symlink(resolved, aliasPath, 'junction');
return aliasPath;
}
function mapPermissionMode(permissionMode) {
if (permissionMode === 'bypassPermissions') {
return { sandboxMode: 'danger-full-access', approvalPolicy: 'never' };
}
if (permissionMode === 'acceptEdits') {
return { sandboxMode: 'workspace-write', approvalPolicy: 'never' };
}
return { sandboxMode: 'workspace-write', approvalPolicy: 'never' };
}
function normalizeReasoningEffort(reasoningEffort) {
const value = String(reasoningEffort || '').trim();
return ['minimal', 'low', 'medium', 'high', 'xhigh'].includes(value) ? value : undefined;
}
function isSpawnPermissionError(error) {
return error?.code === 'EPERM' && String(error?.syscall || '').startsWith('spawn');
}
function userFacingCodexError(error) {
const message = String(error?.message || 'Codex task failed');
if (process.platform === 'win32' && isSpawnPermissionError(error)) {
return [
'Codex 执行器启动被 Windows 拒绝(spawn EPERM)。',
'通常是后台服务从受限环境启动导致的,请重启正式服务后再试。'
].join(' ');
}
return message;
}
function codexErrorDiagnostics(error) {
return {
message: error?.message || '',
code: error?.code || '',
errno: error?.errno || '',
syscall: error?.syscall || '',
path: error?.path || '',
spawnargs: Array.isArray(error?.spawnargs) ? error.spawnargs : [],
cwd: process.cwd(),
execPath: process.execPath,
pathLength: String(process.env.Path || process.env.PATH || '').length
};
}
export async function runCodexTurn({ sessionId, draftSessionId, projectPath, message, model, reasoningEffort, permissionMode, turnId: providedTurnId }, emit) {
const { Codex } = await import('@openai/codex-sdk');
const workingDirectory = await ensureAsciiWorkingDirectory(projectPath);
const { sandboxMode, approvalPolicy } = mapPermissionMode(permissionMode);
const feishuSkillKeys = detectFeishuSkillKeys(message);
const normalizedReasoningEffort = normalizeReasoningEffort(reasoningEffort);
const modelReasoningEffort =
feishuSkillKeys.length && normalizedReasoningEffort === 'xhigh' ? 'low' : normalizedReasoningEffort;
const larkCliContext = await buildCodexLarkCliContext(message).catch((error) => {
console.warn('[lark-cli] Codex context disabled:', error.message);
return { enabled: false, env: { ...process.env }, instruction: '' };
});
const abortController = new AbortController();
const turnId = providedTurnId || crypto.randomUUID();
const state = { hadAssistantText: false, failed: false, usage: null };
const run = {
thread: null,
abortController,
turnId,
sessionId: sessionId || draftSessionId || null,
previousSessionId: draftSessionId || sessionId || null,
startedAt: new Date().toISOString(),
status: 'running'
};
let currentSessionId = sessionId || null;
let previousSessionId = draftSessionId || sessionId || null;
let thread = null;
try {
if (larkCliContext.enabled && larkCliContext.env) {
larkCliContext.env.CODEXMOBILE_TURN_ID = turnId;
larkCliContext.env.CODEXMOBILE_SESSION_ID = sessionId || draftSessionId || '';
}
const codex = new Codex({ env: larkCliContext.env || { ...process.env } });
const threadOptions = {
workingDirectory,
skipGitRepoCheck: true,
sandboxMode,
approvalPolicy,
model,
modelReasoningEffort,
...(larkCliContext.enabled ? { networkAccessEnabled: true } : {})
};
thread = sessionId ? codex.resumeThread(sessionId, threadOptions) : codex.startThread(threadOptions);
currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;
run.thread = thread;
run.sessionId = currentSessionId;
activeRuns.set(turnId, run);
emit({
type: 'chat-started',
sessionId: currentSessionId,
previousSessionId,
turnId,
projectPath,
startedAt: new Date().toISOString()
});
emitStatus(emit, { sessionId: currentSessionId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' });
const codexInput = [message, CODEXMOBILE_REPLY_INSTRUCTION, larkCliContext.enabled ? larkCliContext.instruction : '']
.filter(Boolean)
.join('\n\n');
const streamedTurn = await thread.runStreamed(codexInput, { signal: abortController.signal });
for await (const event of streamedTurn.events) {
const threadId = event.thread_id || event.id || event.payload?.id;
if (event.type === 'thread.started' && threadId) {
const fromSessionId = previousSessionId || currentSessionId;
if (threadId !== currentSessionId) {
currentSessionId = threadId;
run.sessionId = threadId;
}
previousSessionId = fromSessionId;
run.previousSessionId = fromSessionId;
emit({
type: 'thread-started',
sessionId: threadId,
previousSessionId: fromSessionId,
turnId,
projectPath,
startedAt: new Date().toISOString()
});
emitStatus(emit, { sessionId: threadId, turnId, kind: 'reasoning', status: 'running', label: '正在思考' });
continue;
}
if (run.status === 'aborted') {
break;
}
emitCodexEvent(event, currentSessionId, turnId, emit, state);
}
if (!state.failed) {
emit({
type: 'chat-complete',
sessionId: currentSessionId,
previousSessionId,
turnId,
usage: state.usage,
hadAssistantText: state.hadAssistantText,
completedAt: new Date().toISOString()
});
}
} catch (error) {
const wasAborted =
error?.name === 'AbortError' ||
String(error?.message || '').toLowerCase().includes('aborted') ||
activeRuns.get(turnId)?.status === 'aborted';
const userError = userFacingCodexError(error);
emit({
type: wasAborted ? 'chat-aborted' : 'chat-error',
sessionId: currentSessionId,
turnId,
error: wasAborted ? null : userError
});
if (!wasAborted) {
console.error('[codex] Chat error:', codexErrorDiagnostics(error));
emitStatus(emit, {
sessionId: currentSessionId,
turnId,
kind: 'turn',
status: 'failed',
label: '任务失败',
detail: userError
});
}
} finally {
if (activeRuns.has(turnId)) {
const activeRun = activeRuns.get(turnId);
activeRun.status = activeRun.status === 'aborted' ? 'aborted' : 'completed';
activeRuns.delete(turnId);
}
}
return currentSessionId;
}
function runMatchesIdentifier(run, identifier) {
return (
Boolean(identifier) &&
(run.turnId === identifier || run.sessionId === identifier || run.previousSessionId === identifier)
);
}
export function abortCodexTurn(identifier) {
const id = String(identifier || '').trim();
const runs = [...activeRuns.values()].filter(
(run) => run.status === 'running' && runMatchesIdentifier(run, id)
);
if (!runs.length) {
return false;
}
for (const run of runs) {
run.status = 'aborted';
run.abortController.abort();
}
return true;
}
export function getActiveRuns() {
return [...activeRuns.values()]
.filter((run) => run.status === 'running')
.map((run) => ({
sessionId: run.sessionId,
previousSessionId: run.previousSessionId,
startedAt: run.startedAt,
status: run.status,
turnId: run.turnId
}));
}