396 lines
19 KiB
TypeScript
396 lines
19 KiB
TypeScript
import crypto from 'node:crypto';
|
|
import type { ServerNotification } from '../generated/codex/0.154.0-alpha.6.2/ts/ServerNotification';
|
|
import type { ServerRequest } from '../generated/codex/0.154.0-alpha.6.2/ts/ServerRequest';
|
|
import type { GetAccountResponse } from '../generated/codex/0.154.0-alpha.6.2/ts/v2/GetAccountResponse';
|
|
import type { LoginAccountResponse } from '../generated/codex/0.154.0-alpha.6.2/ts/v2/LoginAccountResponse';
|
|
import type {
|
|
CodingAgentAdapter,
|
|
CodingAgentAdapterSession,
|
|
CodingAgentAdapterTurn,
|
|
CodingAgentApprovalDecision,
|
|
CodingAgentEventHandler,
|
|
CodingAgentStartSessionInput,
|
|
CodingAgentStartTurnInput,
|
|
} from '../../shared/codingAgentEvents';
|
|
import { CodexProcessManager, type CodexProcessManagerOptions } from './codexProcessManager';
|
|
|
|
export const CODEX_APP_SERVER_VERSION = '0.154.0-alpha.6.2';
|
|
export const CODEX_APP_SERVER_SCHEMA_DIGEST = '20de09a3e153de373a5b6a6270605af4e05e2f4a0cbb3af2e4a548120a4e3007';
|
|
|
|
type PendingApproval = {
|
|
method: ServerRequest['method'];
|
|
rpcId: string | number;
|
|
codingSessionId: string;
|
|
turnId: string;
|
|
requestedPermissions?: Record<string, unknown>;
|
|
};
|
|
|
|
const IGNORED_NOTIFICATIONS = new Set([
|
|
'thread/started', 'thread/status/changed', 'serverRequest/resolved',
|
|
'account/updated', 'account/rateLimits/updated', 'model/verification', 'model/rerouted',
|
|
'warning', 'guardianWarning', 'configWarning', 'deprecationNotice',
|
|
'remoteControl/status/changed', 'app/list/updated', 'skills/changed', 'project/changed',
|
|
'mcpServer/startupStatus/updated', 'modelProvider/authRecoveryStarted',
|
|
'modelProvider/authRecoveryCompleted', 'model/safetyBuffering/updated',
|
|
'turn/moderationMetadata', 'fs/changed', 'thread/tokenUsage/updated',
|
|
'item/reasoning/summaryTextDelta', 'item/reasoning/summaryPartAdded', 'item/reasoning/textDelta',
|
|
]);
|
|
const MAX_COMMAND_OUTPUT_CHARS_PER_TURN = 32_000;
|
|
|
|
function diffSummary(diff: string): Record<string, unknown> {
|
|
return {
|
|
byteSize: Buffer.byteLength(diff, 'utf8'),
|
|
lineCount: diff ? diff.split('\n').length : 0,
|
|
digest: crypto.createHash('sha256').update(diff).digest('hex'),
|
|
};
|
|
}
|
|
|
|
function safeItemPayload(item: any): Record<string, unknown> {
|
|
const payload: Record<string, unknown> = { type: String(item.type), status: String(item.status || '') };
|
|
if (item.type === 'commandExecution') payload.command = item.command;
|
|
if (item.type === 'fileChange') payload.changeCount = Array.isArray(item.changes) ? item.changes.length : 0;
|
|
if (item.type === 'mcpToolCall' || item.type === 'dynamicToolCall') {
|
|
payload.tool = item.tool || item.name || null;
|
|
payload.server = item.server || item.serverName || null;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export class CodexAppServerAdapter implements CodingAgentAdapter {
|
|
readonly key = 'codex-app-server';
|
|
private readonly manager: CodexProcessManager;
|
|
private readonly handlers = new Map<string, CodingAgentEventHandler>();
|
|
private readonly sessionByThread = new Map<string, string>();
|
|
private readonly localTurnByProviderTurn = new Map<string, string>();
|
|
private readonly pendingLocalTurnByThread = new Map<string, string>();
|
|
private readonly activeTurnBySession = new Map<string, string>();
|
|
private readonly approvals = new Map<string, PendingApproval>();
|
|
private readonly localItemByProviderItem = new Map<string, string>();
|
|
private readonly commandOutputCharsByTurn = new Map<string, number>();
|
|
|
|
constructor(options: Omit<CodexProcessManagerOptions, 'expectedVersion'> = {}) {
|
|
this.manager = new CodexProcessManager({ ...options, expectedVersion: CODEX_APP_SERVER_VERSION });
|
|
this.manager.onNotification((message) => this.handleNotification(message as ServerNotification));
|
|
this.manager.onServerRequest((message) => this.handleServerRequest(message as ServerRequest));
|
|
this.manager.onExit((error) => {
|
|
for (const [codingSessionId, handler] of this.handlers) {
|
|
const turnId = this.activeTurnBySession.get(codingSessionId);
|
|
if (turnId) void handler({
|
|
kind: 'turn.recovery_required', turnId,
|
|
payload: { code: 'CODEX_PROCESS_EXITED', message: error.message },
|
|
});
|
|
this.handlers.delete(codingSessionId);
|
|
}
|
|
this.activeTurnBySession.clear();
|
|
});
|
|
}
|
|
|
|
async health() {
|
|
try {
|
|
await this.manager.start();
|
|
return {
|
|
healthy: true, adapterKey: this.key, adapterVersion: CODEX_APP_SERVER_VERSION,
|
|
schemaDigest: CODEX_APP_SERVER_SCHEMA_DIGEST,
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
healthy: false, adapterKey: this.key, adapterVersion: CODEX_APP_SERVER_VERSION,
|
|
schemaDigest: CODEX_APP_SERVER_SCHEMA_DIGEST, detail: error?.message || 'Codex App Server is unavailable',
|
|
};
|
|
}
|
|
}
|
|
|
|
async readAccount(refreshToken = false): Promise<GetAccountResponse> {
|
|
await this.manager.start();
|
|
return this.manager.request('account/read', { refreshToken });
|
|
}
|
|
|
|
async startChatGptLogin(): Promise<LoginAccountResponse> {
|
|
await this.manager.start();
|
|
return this.manager.request('account/login/start', { type: 'chatgptDeviceCode' });
|
|
}
|
|
|
|
async logoutAccount(): Promise<void> {
|
|
await this.manager.start();
|
|
await this.manager.request('account/logout', {});
|
|
}
|
|
|
|
async startSession(input: CodingAgentStartSessionInput, onEvent: CodingAgentEventHandler): Promise<CodingAgentAdapterSession> {
|
|
await this.manager.start();
|
|
const response: any = await this.manager.request('thread/start', {
|
|
cwd: input.workspacePath,
|
|
approvalPolicy: 'on-request',
|
|
serviceName: 'rokcet',
|
|
ephemeral: false,
|
|
});
|
|
const providerThreadId = String(response?.thread?.id || '');
|
|
if (!providerThreadId) throw new Error('Codex App Server thread/start returned no thread id');
|
|
this.handlers.set(input.codingSessionId, onEvent);
|
|
this.sessionByThread.set(providerThreadId, input.codingSessionId);
|
|
return { providerThreadId, providerSessionId: response?.thread?.sessionId || null };
|
|
}
|
|
|
|
async resumeSession(providerThreadId: string, input: CodingAgentStartSessionInput, onEvent: CodingAgentEventHandler): Promise<CodingAgentAdapterSession> {
|
|
await this.manager.start();
|
|
const response: any = await this.manager.request('thread/resume', {
|
|
threadId: providerThreadId,
|
|
cwd: input.workspacePath,
|
|
approvalPolicy: 'on-request',
|
|
excludeTurns: true,
|
|
});
|
|
const resumedThreadId = String(response?.thread?.id || '');
|
|
if (resumedThreadId !== providerThreadId) throw new Error('Codex App Server resumed an unexpected thread');
|
|
this.handlers.set(input.codingSessionId, onEvent);
|
|
this.sessionByThread.set(providerThreadId, input.codingSessionId);
|
|
return { providerThreadId, providerSessionId: response?.thread?.sessionId || null };
|
|
}
|
|
|
|
async startTurn(providerThreadId: string, input: CodingAgentStartTurnInput): Promise<CodingAgentAdapterTurn> {
|
|
this.pendingLocalTurnByThread.set(providerThreadId, input.turnId);
|
|
this.activeTurnBySession.set(input.codingSessionId, input.turnId);
|
|
try {
|
|
const response: any = await this.manager.request('turn/start', {
|
|
threadId: providerThreadId,
|
|
input: [{ type: 'text', text: input.prompt }],
|
|
cwd: input.workspacePath,
|
|
approvalPolicy: 'on-request',
|
|
sandboxPolicy: {
|
|
type: 'workspaceWrite', writableRoots: [input.workspacePath], networkAccess: false,
|
|
excludeTmpdirEnvVar: false, excludeSlashTmp: false,
|
|
},
|
|
});
|
|
const providerTurnId = String(response?.turn?.id || '');
|
|
if (!providerTurnId) throw new Error('Codex App Server turn/start returned no turn id');
|
|
this.localTurnByProviderTurn.set(providerTurnId, input.turnId);
|
|
return { providerTurnId };
|
|
} catch (error) {
|
|
this.activeTurnBySession.delete(input.codingSessionId);
|
|
throw error;
|
|
} finally {
|
|
this.pendingLocalTurnByThread.delete(providerThreadId);
|
|
}
|
|
}
|
|
|
|
async interruptTurn(providerThreadId: string, providerTurnId: string): Promise<void> {
|
|
await this.manager.request('turn/interrupt', { threadId: providerThreadId, turnId: providerTurnId });
|
|
}
|
|
|
|
async startReview(providerThreadId: string, target: { type: 'uncommitted_changes' | 'commit'; sha?: string }, turnId: string): Promise<CodingAgentAdapterTurn> {
|
|
const codingSessionId = this.sessionByThread.get(providerThreadId);
|
|
if (!codingSessionId) throw new Error('Provider thread is not attached to a Rokcet session');
|
|
this.pendingLocalTurnByThread.set(providerThreadId, turnId);
|
|
this.activeTurnBySession.set(codingSessionId, turnId);
|
|
try {
|
|
const response: any = await this.manager.request('review/start', {
|
|
threadId: providerThreadId,
|
|
delivery: 'inline',
|
|
target: target.type === 'commit'
|
|
? { type: 'commit', sha: target.sha, title: null }
|
|
: { type: 'uncommittedChanges' },
|
|
});
|
|
const providerTurnId = String(response?.turn?.id || '');
|
|
if (!providerTurnId) throw new Error('Codex App Server review/start returned no turn id');
|
|
this.localTurnByProviderTurn.set(providerTurnId, turnId);
|
|
return { providerTurnId };
|
|
} catch (error) {
|
|
this.activeTurnBySession.delete(codingSessionId);
|
|
throw error;
|
|
} finally {
|
|
this.pendingLocalTurnByThread.delete(providerThreadId);
|
|
}
|
|
}
|
|
|
|
async resolveApproval(providerRequestId: string, decision: CodingAgentApprovalDecision, details: Record<string, unknown> = {}): Promise<void> {
|
|
const pending = this.approvals.get(providerRequestId);
|
|
if (!pending) throw new Error('Codex approval request is no longer pending');
|
|
if (pending.method === 'item/commandExecution/requestApproval' || pending.method === 'item/fileChange/requestApproval') {
|
|
const codexDecision = decision === 'accept_for_session' ? 'acceptForSession' : decision;
|
|
this.manager.respond(pending.rpcId, { decision: codexDecision });
|
|
} else if (pending.method === 'item/permissions/requestApproval') {
|
|
this.manager.respond(pending.rpcId, {
|
|
permissions: decision === 'accept' || decision === 'accept_for_session' ? (pending.requestedPermissions || {}) : {},
|
|
...(decision === 'accept_for_session' ? { scope: 'session' } : { scope: 'turn' }),
|
|
});
|
|
} else if (pending.method === 'item/tool/requestUserInput') {
|
|
this.manager.respond(pending.rpcId, { answers: decision === 'accept' ? (details.answers || {}) : {} });
|
|
} else if (pending.method === 'mcpServer/elicitation/request') {
|
|
this.manager.respond(pending.rpcId, {
|
|
action: decision === 'accept' ? 'accept' : decision === 'cancel' ? 'cancel' : 'decline',
|
|
content: decision === 'accept' ? (details.content || {}) : null,
|
|
});
|
|
} else {
|
|
throw new Error(`Unsupported Codex approval method: ${pending.method}`);
|
|
}
|
|
this.approvals.delete(providerRequestId);
|
|
}
|
|
|
|
async shutdown(): Promise<void> {
|
|
await this.manager.stop();
|
|
this.handlers.clear();
|
|
this.sessionByThread.clear();
|
|
this.localTurnByProviderTurn.clear();
|
|
this.pendingLocalTurnByThread.clear();
|
|
this.activeTurnBySession.clear();
|
|
this.approvals.clear();
|
|
this.localItemByProviderItem.clear();
|
|
this.commandOutputCharsByTurn.clear();
|
|
}
|
|
|
|
private localTurn(threadId: string, providerTurnId: string): string | null {
|
|
return this.localTurnByProviderTurn.get(providerTurnId) || this.pendingLocalTurnByThread.get(threadId) || null;
|
|
}
|
|
|
|
private handlerForThread(threadId: string): { codingSessionId: string; handler: CodingAgentEventHandler } | null {
|
|
const codingSessionId = this.sessionByThread.get(threadId);
|
|
const handler = codingSessionId ? this.handlers.get(codingSessionId) : null;
|
|
return codingSessionId && handler ? { codingSessionId, handler } : null;
|
|
}
|
|
|
|
private localItem(threadId: string, providerItemId: unknown): string | null {
|
|
const providerId = typeof providerItemId === 'string' ? providerItemId : '';
|
|
if (!providerId) return null;
|
|
const key = `${threadId}:${providerId}`;
|
|
const existing = this.localItemByProviderItem.get(key);
|
|
if (existing) return existing;
|
|
const localId = crypto.randomUUID();
|
|
this.localItemByProviderItem.set(key, localId);
|
|
return localId;
|
|
}
|
|
|
|
private async handleNotification(message: ServerNotification): Promise<void> {
|
|
const params: any = message.params || {};
|
|
const scoped = typeof params.threadId === 'string' ? this.handlerForThread(params.threadId) : null;
|
|
if (message.method === 'turn/started') {
|
|
if (!scoped) return;
|
|
const providerTurnId = String(params.turn?.id || '');
|
|
const turnId = this.localTurn(params.threadId, providerTurnId);
|
|
if (!turnId) throw new Error('Codex started an unmapped turn');
|
|
this.localTurnByProviderTurn.set(providerTurnId, turnId);
|
|
await scoped.handler({ kind: 'turn.started', turnId, payload: {} });
|
|
return;
|
|
}
|
|
if (message.method === 'turn/completed') {
|
|
if (!scoped) return;
|
|
const providerTurnId = String(params.turn?.id || '');
|
|
const turnId = this.localTurn(params.threadId, providerTurnId);
|
|
if (!turnId) throw new Error('Codex completed an unmapped turn');
|
|
const status = String(params.turn?.status || 'failed');
|
|
this.activeTurnBySession.delete(scoped.codingSessionId);
|
|
this.commandOutputCharsByTurn.delete(turnId);
|
|
await scoped.handler({
|
|
kind: status === 'interrupted' ? 'turn.interrupted' : status === 'failed' ? 'turn.failed' : 'turn.completed',
|
|
turnId,
|
|
payload: status === 'failed'
|
|
? { code: 'CODEX_TURN_FAILED', message: String(params.turn?.error?.message || 'Codex turn failed') }
|
|
: { status },
|
|
});
|
|
return;
|
|
}
|
|
if (message.method === 'item/agentMessage/delta') {
|
|
if (!scoped) return;
|
|
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
|
|
if (!turnId) throw new Error('Codex emitted a delta for an unmapped turn');
|
|
await scoped.handler({ kind: 'assistant.delta', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { delta: String(params.delta || '') } });
|
|
return;
|
|
}
|
|
if (message.method === 'item/commandExecution/outputDelta') {
|
|
if (!scoped) return;
|
|
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
|
|
if (!turnId) throw new Error('Codex emitted command output for an unmapped turn');
|
|
const delta = String(params.delta || '');
|
|
const used = this.commandOutputCharsByTurn.get(turnId) || 0;
|
|
const remaining = Math.max(0, MAX_COMMAND_OUTPUT_CHARS_PER_TURN - used);
|
|
if (remaining > 0) {
|
|
const bounded = delta.slice(0, remaining);
|
|
this.commandOutputCharsByTurn.set(turnId, used + bounded.length);
|
|
await scoped.handler({ kind: 'command.output', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { delta: bounded } });
|
|
}
|
|
if (delta.length > remaining && used <= MAX_COMMAND_OUTPUT_CHARS_PER_TURN) {
|
|
this.commandOutputCharsByTurn.set(turnId, MAX_COMMAND_OUTPUT_CHARS_PER_TURN + 1);
|
|
await scoped.handler({ kind: 'stream.truncated', turnId, itemId: this.localItem(params.threadId, params.itemId), payload: { stream: 'command.output', limitChars: MAX_COMMAND_OUTPUT_CHARS_PER_TURN } });
|
|
}
|
|
return;
|
|
}
|
|
if (message.method === 'turn/diff/updated') {
|
|
if (!scoped) return;
|
|
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
|
|
if (!turnId) throw new Error('Codex emitted a diff for an unmapped turn');
|
|
await scoped.handler({ kind: 'diff.updated', turnId, payload: diffSummary(String(params.diff || '')) });
|
|
return;
|
|
}
|
|
if (message.method === 'turn/plan/updated') {
|
|
if (!scoped) return;
|
|
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
|
|
if (turnId) await scoped.handler({ kind: 'plan.updated', turnId, payload: { plan: params.plan || [] } });
|
|
return;
|
|
}
|
|
if (message.method === 'item/started' || message.method === 'item/completed') {
|
|
if (!scoped) return;
|
|
const item = params.item as any;
|
|
const turnId = this.localTurn(params.threadId, String(params.turnId || ''));
|
|
if (!turnId || !item?.type || !item?.id) throw new Error('Codex emitted a malformed item lifecycle');
|
|
const suffix = message.method === 'item/started' ? 'started' : 'completed';
|
|
const kind = item.type === 'commandExecution' ? `command.${suffix}`
|
|
: item.type === 'fileChange' ? `file_change.${suffix}`
|
|
: item.type === 'mcpToolCall' || item.type === 'dynamicToolCall' ? `tool.${suffix}`
|
|
: item.type === 'agentMessage' && suffix === 'completed' ? 'assistant.completed'
|
|
: null;
|
|
if (kind) await scoped.handler({
|
|
kind: kind as any, turnId,
|
|
itemId: this.localItem(params.threadId, item.id),
|
|
payload: safeItemPayload(item),
|
|
});
|
|
return;
|
|
}
|
|
if (message.method === 'error') {
|
|
if (!scoped) return;
|
|
const providerTurnId = String(params.turnId || '');
|
|
const turnId = this.localTurn(params.threadId, providerTurnId);
|
|
if (turnId) await scoped.handler({ kind: 'turn.failed', turnId, payload: { code: 'CODEX_ERROR', message: String(params.error?.message || 'Codex error') } });
|
|
return;
|
|
}
|
|
if (IGNORED_NOTIFICATIONS.has(message.method)) return;
|
|
throw new Error(`Unsupported Codex notification: ${(message as any).method}`);
|
|
}
|
|
|
|
private async handleServerRequest(message: ServerRequest): Promise<void> {
|
|
const params: any = message.params || {};
|
|
const scoped = this.handlerForThread(String(params.threadId || ''));
|
|
if (!scoped) throw new Error('Codex approval request is not scoped to a Rokcet session');
|
|
const turnId = this.localTurn(String(params.threadId || ''), String(params.turnId || ''));
|
|
if (!turnId) throw new Error('Codex approval request is not scoped to a Rokcet turn');
|
|
if (![
|
|
'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
|
|
'item/tool/requestUserInput', 'mcpServer/elicitation/request', 'item/permissions/requestApproval',
|
|
].includes(message.method)) throw new Error(`Unsupported Codex server request: ${message.method}`);
|
|
const providerRequestId = String(message.id);
|
|
this.approvals.set(providerRequestId, {
|
|
method: message.method, rpcId: message.id,
|
|
codingSessionId: scoped.codingSessionId, turnId,
|
|
...(params.permissions && typeof params.permissions === 'object' && !Array.isArray(params.permissions)
|
|
? { requestedPermissions: params.permissions as Record<string, unknown> }
|
|
: {}),
|
|
});
|
|
const kind = message.method === 'item/fileChange/requestApproval' ? 'file_change'
|
|
: message.method === 'item/permissions/requestApproval' ? 'permission'
|
|
: message.method === 'item/tool/requestUserInput' ? 'user_input'
|
|
: message.method === 'mcpServer/elicitation/request' ? 'mcp_form'
|
|
: params.networkApprovalContext ? 'network' : 'command';
|
|
const payload: Record<string, unknown> = {
|
|
providerRequestId, kind,
|
|
allowedDecisions: ['accept', 'accept_for_session', 'decline', 'cancel'],
|
|
};
|
|
for (const key of ['reason', 'command', 'networkApprovalContext', 'questions', 'message', 'requestedSchema'] as const) {
|
|
if (params[key] !== undefined) payload[key] = params[key];
|
|
}
|
|
if (params.permissions && typeof params.permissions === 'object' && !Array.isArray(params.permissions)) {
|
|
payload.permissionKinds = Object.keys(params.permissions);
|
|
}
|
|
await scoped.handler({
|
|
kind: 'approval.requested', turnId,
|
|
itemId: this.localItem(String(params.threadId || ''), params.itemId),
|
|
payload,
|
|
});
|
|
}
|
|
}
|