diff --git a/.claude/skills/tech-lead/SKILL.md b/.claude/skills/tech-lead/SKILL.md index 120c206d..3e8dcd63 100644 --- a/.claude/skills/tech-lead/SKILL.md +++ b/.claude/skills/tech-lead/SKILL.md @@ -155,27 +155,39 @@ Provide a structured complexity assessment in this format: **Complexity Criteria:** +> **IMPORTANT — Evaluation logic:** +> Rate as **Complex** if ANY single Complex criterion below applies. +> Rate as **Medium** only when NO Complex criteria apply AND multiple Medium criteria apply. +> Rate as **Simple** only when ALL Simple criteria apply. +> File count is a supporting signal, NOT the primary driver — qualitative criteria take precedence. + +**Hard triggers → always Complex (any one is sufficient):** +- New integration with an external tool, CLI, or service (new agent plugin, new provider, new API client) +- Novel parsing or calculation logic that must be reverse-engineered (undocumented formats, no existing adapter) +- Significant architectural decisions needed (new interface contracts, new layer interactions) +- Cross-cutting concerns that affect security, reliability, or metrics pipeline integrity + **Simple:** - Single component affected - Well-defined requirements -- Existing patterns to follow +- Existing patterns to follow exactly (copy-paste with trivial changes) - No architectural decisions needed - Estimated 1-3 files to change **Medium:** -- 2-3 components affected +- 2-3 components affected (no hard triggers above) - Clear requirements with minor gaps -- May require some architectural decisions +- Adapts an existing pattern with moderate changes - Estimated 4-8 files to change -- Requires coordination between layers +- Requires coordination between layers but no new contracts **Complex:** -- 4+ components affected -- Ambiguous or incomplete requirements -- Significant architectural decisions needed +- Any hard trigger above applies, OR +- 4+ distinct components affected +- Ambiguous or incomplete requirements requiring significant inference - Estimated 9+ files to change -- Cross-cutting concerns (security, performance) - New integrations or external dependencies +- Novel computation / parsing logic with no existing reference in codebase ### Phase 4: Recommendation diff --git a/bin/codemie-codex.js b/bin/codemie-codex.js new file mode 100755 index 00000000..8ce77c3c --- /dev/null +++ b/bin/codemie-codex.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +/** + * Codex Agent Entry Point + * Direct entry point for codemie-codex command + */ + +import { AgentCLI } from '../dist/agents/core/AgentCLI.js'; +import { AgentRegistry } from '../dist/agents/registry.js'; + +const agent = AgentRegistry.getAgent('codex'); +if (!agent) { + console.error('✗ Codex agent not found in registry'); + process.exit(1); +} + +const cli = new AgentCLI(agent); +await cli.run(process.argv); diff --git a/package.json b/package.json index 09a77c4a..9a17f42f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "codemie-claude": "./bin/codemie-claude.js", "codemie-claude-acp": "./bin/codemie-claude-acp.js", "codemie-gemini": "./bin/codemie-gemini.js", - "codemie-opencode": "./bin/codemie-opencode.js" + "codemie-opencode": "./bin/codemie-opencode.js", + "codemie-codex": "./bin/codemie-codex.js" }, "files": [ "dist", diff --git a/src/agents/__tests__/registry.test.ts b/src/agents/__tests__/registry.test.ts index 950e8502..0a2fbb87 100644 --- a/src/agents/__tests__/registry.test.ts +++ b/src/agents/__tests__/registry.test.ts @@ -12,8 +12,8 @@ describe('AgentRegistry', () => { it('should register all default agents', () => { const agentNames = AgentRegistry.getAgentNames(); - // Should have all 5 default agents (codemie-code, claude, claude-acp, gemini, opencode) - expect(agentNames).toHaveLength(5); + // Should have all 6 default agents (codemie-code, claude, claude-acp, gemini, opencode, codex) + expect(agentNames).toHaveLength(6); }); it('should register built-in agent', () => { @@ -62,7 +62,7 @@ describe('AgentRegistry', () => { it('should return all registered agents', () => { const agents = AgentRegistry.getAllAgents(); - expect(agents).toHaveLength(5); + expect(agents).toHaveLength(6); expect(agents.every((agent) => agent.name)).toBe(true); }); @@ -74,6 +74,7 @@ describe('AgentRegistry', () => { expect(names).toContain('claude-acp'); expect(names).toContain('gemini'); expect(names).toContain('opencode'); + expect(names).toContain('codex'); }); }); diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 8b7904d0..4dbd6aaf 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -12,6 +12,7 @@ import { CodeMieCodePluginMetadata } from '../plugins/codemie-code.plugin.js'; import { GeminiPluginMetadata } from '../plugins/gemini/gemini.plugin.js'; import { OpenCodePluginMetadata } from '../plugins/opencode/opencode.plugin.js'; import {ClaudeAcpPluginMetadata} from "../plugins/claude/claude-acp.plugin.js"; +import { CodexPluginMetadata } from '../plugins/codex/codex.plugin.js'; /** * Universal CLI builder for any agent @@ -408,6 +409,7 @@ export class AgentCLI { 'gemini': GeminiPluginMetadata, 'opencode': OpenCodePluginMetadata, 'claude-acp': ClaudeAcpPluginMetadata, + 'codex': CodexPluginMetadata, }; return metadataMap[this.adapter.name]; } @@ -425,8 +427,8 @@ export class AgentCLI { const provider = config.provider || 'unknown'; const model = config.model || 'unknown'; - // Check provider compatibility - if (!metadata.supportedProviders.includes(provider)) { + // Check provider compatibility (skip when empty — agent manages its own auth) + if (metadata.supportedProviders.length > 0 && !metadata.supportedProviders.includes(provider)) { logger.error(`Provider '${provider}' is not supported by ${this.adapter.displayName}`); console.log(chalk.white(`\nSupported providers: ${metadata.supportedProviders.join(', ')}`)); console.log(chalk.white('\nOptions:')); diff --git a/src/agents/core/session/types.ts b/src/agents/core/session/types.ts index 4fc525c3..5d3e050e 100644 --- a/src/agents/core/session/types.ts +++ b/src/agents/core/session/types.ts @@ -5,6 +5,15 @@ * These types are shared across metrics, conversations, and other processors. */ +/** + * Base normalized message format shared across all conversation processors. + */ +export interface BaseNormalizedMessage { + role: 'user' | 'assistant'; + content: string; + timestamp?: string; +} + /** * Correlation status */ diff --git a/src/agents/core/session/utils/jsonl-reader.ts b/src/agents/core/session/utils/jsonl-reader.ts index 3bbd5434..5824648d 100644 --- a/src/agents/core/session/utils/jsonl-reader.ts +++ b/src/agents/core/session/utils/jsonl-reader.ts @@ -9,6 +9,44 @@ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { logger } from '../../../../utils/logger.js'; +/** + * Read all records from JSONL file, skipping corrupted lines. + * + * @param filePath - Absolute path to JSONL file + * @param logPrefix - Log prefix for warnings (e.g. '[codex-storage]') + * @returns Array of parsed records (empty if file doesn't exist; corrupted lines skipped) + */ +export async function readJSONLTolerant(filePath: string, logPrefix = '[jsonl-reader]'): Promise { + try { + const content = await readFile(filePath, 'utf-8'); + const lines = content.trim().split('\n'); + const results: T[] = []; + let corruptedCount = 0; + + for (const line of lines) { + if (!line.trim()) continue; + try { + results.push(JSON.parse(line) as T); + } catch { + corruptedCount++; + } + } + + if (corruptedCount > 0) { + logger.warn(`${logPrefix} Skipped ${corruptedCount} corrupted JSONL lines in ${filePath}`); + } + + return results; + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + return []; + } + logger.debug(`${logPrefix} Failed to read JSONL ${filePath}: ${err.message}`); + return []; + } +} + /** * Read all records from JSONL file. * diff --git a/src/agents/plugins/codex/codex-message-types.ts b/src/agents/plugins/codex/codex-message-types.ts new file mode 100644 index 00000000..cc035120 --- /dev/null +++ b/src/agents/plugins/codex/codex-message-types.ts @@ -0,0 +1,104 @@ +// src/agents/plugins/codex/codex-message-types.ts +/** + * TypeScript types for Codex rollout JSONL records. + * + * Codex writes rollout files at: + * ~/.codex/sessions/YYYY/MM/DD/rollout-{ISO8601}-{uuid}.jsonl + * + * Each line is a JSON object (CodexRolloutRecord) with a `type` discriminator. + * + * Confirmed from codex-cli 0.79.0 rollout file inspection. + * + * References: + * - https://github.com/openai/codex/blob/main/codex-rs/docs/cli-reference.md + * - https://github.com/openai/codex/blob/main/codex-rs/docs/configuration.md + */ + +import { ConfigurationError } from '../../../utils/errors.js'; + +/** Top-level wrapper for every JSONL line in a rollout file */ +export interface CodexRolloutRecord { + type: 'session_meta' | 'turn_context' | 'response_item' | 'event_msg'; + payload: CodexSessionMeta | CodexTurnContext | CodexResponseItem | CodexEventMsg; +} + +/** session_meta record — appears once per rollout file */ +export interface CodexSessionMeta { + id: string; // UUID (also embedded in filename) + timestamp: string; // ISO 8601 wall-clock time of session start + cwd: string; + originator?: string; + cli_version?: string; + model_provider?: string; // Fallback model identifier (provider name, not full model ID) + git?: { + commit_hash?: string; + branch?: string; + repository_url?: string; + }; +} + +/** turn_context record — may appear multiple times; last value wins for model extraction */ +export interface CodexTurnContext { + cwd: string; + approval_policy?: string; + sandbox_policy?: string; + model?: string; // Actual model string passed to the API (primary model source) + summary?: string; +} + +/** + * response_item record — discriminated union on payload.type. + * Relevant sub-types: function_call, function_call_output, message, reasoning. + */ +export interface CodexResponseItem { + type: string; // 'function_call' | 'function_call_output' | 'message' | 'reasoning' | 'ghost_snapshot' + name?: string; // function_call: tool name + arguments?: string; // function_call: JSON-encoded args string + call_id?: string; // function_call + function_call_output: shared correlation ID + output?: string; // function_call_output: tool output +} + +/** event_msg record — user messages and other session events */ +export interface CodexEventMsg { + type: 'user_message' | string; + message?: string; +} + +/** + * Extended ParsedSession metadata specific to the Codex plugin. + * Consumed only within the codex plugin boundary. + */ +export interface CodexSessionMetadata { + projectPath?: string; + createdAt?: string; // ISO 8601 from session_meta.timestamp + repository?: string; + branch?: string; + codexSessionId: string; // UUID from session_meta.id (unique per rollout file) + model?: string; // Resolved model string (turn_context.model or session_meta.model_provider) + cliVersion?: string; +} + +/** + * Type guard: asserts metadata is CodexSessionMetadata. + * Throws if required field codexSessionId is missing. + */ +export function validateCodexMetadata(metadata: unknown): asserts metadata is CodexSessionMetadata { + if ( + typeof metadata !== 'object' || + metadata === null || + typeof (metadata as CodexSessionMetadata).codexSessionId !== 'string' + ) { + throw new ConfigurationError('Invalid Codex session metadata: codexSessionId is required'); + } +} + +/** + * Type guard: returns true if metadata has a valid codexSessionId field. + */ +export function hasCodexMetadata(metadata: unknown): metadata is CodexSessionMetadata { + return ( + typeof metadata === 'object' && + metadata !== null && + typeof (metadata as CodexSessionMetadata).codexSessionId === 'string' + ); +} diff --git a/src/agents/plugins/codex/codex.paths.ts b/src/agents/plugins/codex/codex.paths.ts new file mode 100644 index 00000000..50f2494c --- /dev/null +++ b/src/agents/plugins/codex/codex.paths.ts @@ -0,0 +1,45 @@ +// src/agents/plugins/codex/codex.paths.ts +/** + * Codex path utilities. + * + * Codex stores rollout files at: + * ~/.codex/sessions/YYYY/MM/DD/rollout-{ISO8601}-{uuid}.jsonl + * + * Unlike OpenCode, Codex does NOT use XDG conventions — ~/.codex is fixed. + * + * References: + * - https://github.com/openai/codex/blob/main/codex-rs/docs/configuration.md + */ + +import { homedir } from 'os'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +/** + * Returns the Codex home directory: ~/.codex + */ +export function getCodexHomePath(): string { + return join(homedir(), '.codex'); +} + +/** + * Returns the Codex sessions base directory: ~/.codex/sessions + * Returns null if the directory does not exist (Codex not run yet). + */ +export function getCodexSessionsPath(): string | null { + const sessionsPath = join(homedir(), '.codex', 'sessions'); + return existsSync(sessionsPath) ? sessionsPath : null; +} + +/** + * Returns the day-specific session directory for a given date: + * ~/.codex/sessions/YYYY/MM/DD + * + * Note: This directory may not exist yet. + */ +export function getCodexSessionDayPath(date: Date): string { + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return join(getCodexHomePath(), 'sessions', year, month, day); +} diff --git a/src/agents/plugins/codex/codex.plugin.ts b/src/agents/plugins/codex/codex.plugin.ts new file mode 100644 index 00000000..58c0b078 --- /dev/null +++ b/src/agents/plugins/codex/codex.plugin.ts @@ -0,0 +1,295 @@ +// src/agents/plugins/codex/codex.plugin.ts +/** + * Codex Agent Plugin + * + * Registers OpenAI Codex CLI (@openai/codex) as a selectable agent in CodeMie. + * + * Config injection strategy: + * - CODEMIE_BASE_URL → OPENAI_BASE_URL (env var, picked up natively by Codex) + * - CODEMIE_API_KEY → OPENAI_API_KEY + CODEMIE_API_KEY (env vars via transformEnvVars) + * - Model injected via: --model + * - Provider: model_providers.codemie with env_key=CODEMIE_API_KEY (bypasses ~/.codex/auth.json) + * auth.json has highest priority for the default openai provider; a custom provider with + * env_key pointing to CODEMIE_API_KEY bypasses it since auth.json only covers openai. + * + * Session lifecycle (CLI-level via processEvent): + * 1. onSessionStart → processEvent(SessionStart) — creates session record + sends start metrics + * 2. enrichArgs → transform --task, inject --model + model_providers.codemie + tuning flags + * 3. [Codex runs] + * 4. onSessionEnd → process rollout metrics → processEvent(SessionEnd) — syncs + sends end metrics + * + * References: + * - OpenAI Codex CLI: https://github.com/openai/codex + * - Configuration: https://github.com/openai/codex/blob/main/codex-rs/docs/configuration.md + * - Advanced config: https://developers.openai.com/codex/config-advanced + * - CLI Reference: https://github.com/openai/codex/blob/main/codex-rs/docs/cli-reference.md + */ + +import type { AgentMetadata, AgentConfig } from '../../core/types.js'; +import { BaseAgentAdapter } from '../../core/BaseAgentAdapter.js'; +import type { SessionAdapter } from '../../core/session/BaseSessionAdapter.js'; +import type { BaseExtensionInstaller } from '../../core/extension/BaseExtensionInstaller.js'; +import type { HookProcessingConfig } from '../../../cli/commands/hook.js'; +import { commandExists } from '../../../utils/processes.js'; +import { logger } from '../../../utils/logger.js'; +import { CodexSessionAdapter } from './codex.session.js'; + +/** + * Build a hook config object from environment variables. + * Used by both onSessionStart and onSessionEnd lifecycle hooks. + */ +function buildHookConfig(env: NodeJS.ProcessEnv, sessionId: string): HookProcessingConfig { + return { + agentName: env.CODEMIE_AGENT || 'codex', + sessionId, + provider: env.CODEMIE_PROVIDER, + apiBaseUrl: env.CODEMIE_BASE_URL, + ssoUrl: env.CODEMIE_URL, + version: env.CODEMIE_CLI_VERSION, + profileName: env.CODEMIE_PROFILE_NAME, + project: env.CODEMIE_PROJECT, + model: env.CODEMIE_MODEL, + clientType: 'codemie-codex', + }; +} + +export const CodexPluginMetadata: AgentMetadata = { + name: 'codex', + displayName: 'OpenAI Codex CLI', + description: 'OpenAI Codex CLI - AI coding agent by OpenAI', + npmPackage: '@openai/codex', + cliCommand: process.env.CODEMIE_CODEX_BIN || 'codex', + dataPaths: { + home: '.codex', // ~/.codex is fixed for Codex (no XDG convention) + }, + envMapping: { + // CODEMIE_BASE_URL → OPENAI_BASE_URL (read natively by Codex) + baseUrl: ['OPENAI_BASE_URL'], + // CODEMIE_API_KEY → OPENAI_API_KEY only. + // CODEMIE_API_KEY is intentionally NOT listed here: transformEnvVars deletes all + // vars in this array before re-setting them, which would wipe CODEMIE_API_KEY + // before enrichArgs can use it as env_key for the custom model provider. + // CODEMIE_API_KEY is passed through to the codex process env unchanged. + apiKey: ['OPENAI_API_KEY'], + model: [], + }, + supportedProviders: [], + + lifecycle: { + /** + * Send session start metrics via the CLI-level hook pipeline. + * + * Routes through processEvent(SessionStart) which: + * - Creates the session record in ~/.codemie/sessions/{id}.json (status=active) + * - Sends session start metrics to v1/metrics API (SSO provider only) + */ + async onSessionStart(sessionId: string, env: NodeJS.ProcessEnv) { + try { + const { processEvent } = await import('../../../cli/commands/hook.js'); + const event = { + hook_event_name: 'SessionStart', + session_id: sessionId, + transcript_path: '', + permission_mode: 'default', + cwd: process.cwd(), + source: 'startup', + }; + await processEvent(event, buildHookConfig(env, sessionId)); + logger.info(`[codex] SessionStart hook completed for session ${sessionId}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`[codex] SessionStart hook failed (non-blocking): ${msg}`); + } + }, + + /** + * Transform CodeMie flags into Codex CLI arguments. + * + * Transformations applied (in order): + * 1. --task → exec (non-interactive subcommand) + * 2. config.model → --model + * 3. Custom provider → model_providers.codemie (env_key bypasses ~/.codex/auth.json) + * 4. Session tuning → --config flags (unconditional) + * + * OPENAI_BASE_URL and OPENAI_API_KEY are injected into the process env + * by BaseAgentAdapter.transformEnvVars via envMapping. + */ + enrichArgs(args: string[], config: AgentConfig) { + let enriched = args; + + // 1. Transform --task → exec (non-interactive subcommand) + const taskIndex = enriched.indexOf('--task'); + if (taskIndex !== -1 && taskIndex < enriched.length - 1) { + const taskValue = enriched[taskIndex + 1]; + enriched = [ + 'exec', + ...enriched.slice(0, taskIndex), + ...enriched.slice(taskIndex + 2), + taskValue, + ]; + } + + // 2. Inject model via --model when not already overridden + if (config?.model && !enriched.includes('-m') && !enriched.includes('--model')) { + enriched = ['--model', config.model, ...enriched]; + } + + // 3. Configure a custom model provider to bypass ~/.codex/auth.json. + // auth.json has highest priority for the default "openai" provider and overrides + // even OPENAI_API_KEY env var. Using a custom provider with env_key pointing to + // CODEMIE_API_KEY (set by transformEnvVars) bypasses auth.json entirely, since + // auth.json only stores credentials for the default openai provider. + // --config uses TOML values: strings must be double-quoted. + const sentinel = ['not-required', 'sso-provided', 'proxy-handled']; + if (config?.apiKey && !sentinel.includes(config.apiKey) && config?.baseUrl) { + enriched = [ + '--config', 'model_provider="codemie"', + '--config', 'model_providers.codemie.name="codemie"', + '--config', `model_providers.codemie.base_url="${config.baseUrl}"`, + '--config', 'model_providers.codemie.env_key="CODEMIE_API_KEY"', + '--config', 'model_providers.codemie.wire_api="responses"', + ...enriched, + ]; + } + + // 4. Inject session tuning flags (unconditional). + // --config uses TOML values: integers unquoted, strings double-quoted. + enriched = [ + '--config', 'stream_max_retries=40', + '--config', 'request_max_retries=40', + '--config', 'max_output_tokens=16384', + '--config', 'model_verbosity="medium"', + ...enriched, + ]; + + return enriched; + }, + + /** + * Process Codex session metrics and send session end metrics via CLI-level hook pipeline. + * + * Called by BaseAgentAdapter when Codex exits, BEFORE SessionSyncer. + * + * Steps: + * 1. Discover the most recent rollout file (~/.codex/sessions/YYYY/MM/DD/) + * 2. Parse rollout, extract tool usage, write MetricDelta to JSONL + * (so SessionEnd pipeline can sync it to v1/metrics) + * 3. processEvent(SessionEnd) — full CLI-level pipeline: + * accumulateActiveDuration → incrementalSync → syncToAPI → + * sendSessionEndMetrics → updateStatus → renameFiles + */ + async onSessionEnd(exitCode: number, env: NodeJS.ProcessEnv) { + const sessionId = env.CODEMIE_SESSION_ID; + + if (!sessionId) { + logger.debug('[codex] No CODEMIE_SESSION_ID in environment, skipping session end processing'); + return; + } + + // 1. Process rollout file → MetricDelta JSONL (must run before SessionEnd sync) + try { + logger.info(`[codex] Processing session metrics (code=${exitCode})`); + + const adapter = new CodexSessionAdapter(CodexPluginMetadata); + const sessions = await adapter.discoverSessions({ maxAgeDays: 1 }); + + const RECENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes + const now = Date.now(); + const recentSessions = sessions.filter(s => now - s.createdAt <= RECENT_WINDOW_MS); + + if (recentSessions.length === 0) { + logger.warn('[codex] No rollout file modified in the last 5 minutes, skipping metrics'); + } else { + const latestSession = recentSessions[0]; + logger.debug(`[codex] Processing latest rollout: ${latestSession.sessionId}`); + + const context = { + sessionId, + apiBaseUrl: env.CODEMIE_BASE_URL || '', + cookies: '', + clientType: 'codemie-codex', + version: env.CODEMIE_CLI_VERSION || '1.0.0', + dryRun: false, + }; + + const result = await adapter.processSession(latestSession.filePath, sessionId, context); + + if (result.success) { + logger.info(`[codex] Metrics written to JSONL: ${result.totalRecords} records`); + } else { + logger.warn(`[codex] Metrics processing had failures: ${result.failedProcessors.join(', ')}`); + } + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`[codex] Rollout processing failed (non-blocking): ${msg}`); + } + + // 2. Route through CLI-level SessionEnd pipeline + try { + const { processEvent } = await import('../../../cli/commands/hook.js'); + const event = { + hook_event_name: 'SessionEnd', + session_id: sessionId, + transcript_path: '', + permission_mode: 'default', + cwd: process.cwd(), + reason: exitCode === 0 ? 'exit' : `exit(${exitCode})`, + }; + await processEvent(event, buildHookConfig(env, sessionId)); + logger.info(`[codex] SessionEnd hook completed for session ${sessionId}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + logger.error(`[codex] SessionEnd hook failed (non-blocking): ${msg}`); + } + }, + }, +}; + +/** + * Codex agent plugin + * + * Phase 1: Core plugin with CLI wrapping and session tracking. + * Phase 2: Rollout file analytics — discovery, parsing, MetricDelta writing. + */ +export class CodexPlugin extends BaseAgentAdapter { + private readonly sessionAdapter: SessionAdapter; + + constructor() { + super(CodexPluginMetadata); + this.sessionAdapter = new CodexSessionAdapter(CodexPluginMetadata); + } + + /** + * Check whether the `codex` binary is available on PATH. + * Respects CODEMIE_CODEX_BIN environment variable override. + */ + async isInstalled(): Promise { + const cliCommand = this.metadata.cliCommand; + if (!cliCommand) return false; + + const installed = await commandExists(cliCommand); + + if (!installed) { + logger.debug('[codex-plugin] Codex not installed. Install with:'); + logger.debug('[codex-plugin] codemie install codex'); + logger.debug('[codex-plugin] Or directly: npm i -g @openai/codex'); + } + + return installed; + } + + /** + * Return session adapter for rollout analytics. + */ + getSessionAdapter(): SessionAdapter { + return this.sessionAdapter; + } + + /** + * No extension installer — Codex is installed directly via npm. + */ + getExtensionInstaller(): BaseExtensionInstaller | undefined { + return undefined; + } +} diff --git a/src/agents/plugins/codex/codex.session.ts b/src/agents/plugins/codex/codex.session.ts new file mode 100644 index 00000000..eabb09ff --- /dev/null +++ b/src/agents/plugins/codex/codex.session.ts @@ -0,0 +1,359 @@ +// src/agents/plugins/codex/codex.session.ts +/** + * Codex Session Adapter + * + * Implements SessionAdapter for Codex CLI rollout files. + * + * Rollout files are stored at: + * ~/.codex/sessions/YYYY/MM/DD/rollout-{ISO8601}-{uuid}.jsonl + * + * Discovery uses mtime-based age filtering (D-3): file modification time is + * cheaper than parsing filename timestamps and equally accurate for recency filtering. + * + * Parsing reads JSONL tolerantly (skip malformed lines) and extracts: + * - session_meta: session identity, cwd, git info, cli version + * - turn_context: actual model used (last one wins in multi-turn sessions) + * - response_item: function_call / function_call_output for tool pairing + * - event_msg: user messages + * + * References: + * - https://github.com/openai/codex/blob/main/codex-rs/docs/cli-reference.md + */ + +import { readdir, stat } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import type { + SessionAdapter, + ParsedSession, + AggregatedResult, + SessionDiscoveryOptions, + SessionDescriptor, +} from '../../core/session/BaseSessionAdapter.js'; +import type { SessionProcessor, ProcessingContext } from '../../core/session/BaseProcessor.js'; +import type { AgentMetadata } from '../../core/types.js'; +import type { + CodexRolloutRecord, + CodexSessionMeta, + CodexTurnContext, +} from './codex-message-types.js'; +import { getCodexSessionsPath } from './codex.paths.js'; +import { readCodexJsonlTolerant } from './codex.storage-utils.js'; +import { logger } from '../../../utils/logger.js'; +import { ConfigurationError } from '../../../utils/errors.js'; +import { sanitizeLogArgs } from '../../../utils/security.js'; +import { CodexMetricsProcessor } from './session/processors/codex.metrics-processor.js'; +import { CodexConversationsProcessor } from './session/processors/codex.conversations-processor.js'; + +/** Regex to extract UUID from rollout filename: rollout-{ISO8601}-{uuid}.jsonl */ +const ROLLOUT_UUID_REGEX = /rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/; + +export class CodexSessionAdapter implements SessionAdapter { + readonly agentName = 'codex'; + private processors: SessionProcessor[] = []; + + constructor(private readonly metadata: AgentMetadata) { + if (!metadata.dataPaths?.home) { + throw new ConfigurationError('Agent metadata must provide dataPaths.home'); + } + this.initializeProcessors(); + } + + private initializeProcessors(): void { + this.registerProcessor(new CodexMetricsProcessor()); + this.registerProcessor(new CodexConversationsProcessor()); + logger.debug(`[codex-adapter] Initialized ${this.processors.length} processors`); + } + + registerProcessor(processor: SessionProcessor): void { + this.processors.push(processor); + this.processors.sort((a, b) => a.priority - b.priority); + logger.debug(`[codex-adapter] Registered processor: ${processor.name} (priority: ${processor.priority})`); + } + + /** + * Discover Codex rollout files within maxAgeDays. + * + * Algorithm: + * 1. Resolve base path via getCodexSessionsPath() + * 2. Return [] if path does not exist + * 3. Enumerate YYYY/MM/DD directories (3-level traversal) + * 4. For each .jsonl file: extract UUID, stat mtime, apply age filter + * 5. Return sorted by mtime descending (newest first) + */ + async discoverSessions(options?: SessionDiscoveryOptions): Promise { + const sessionsPath = getCodexSessionsPath(); + if (!sessionsPath) { + logger.debug('[codex-discovery] ~/.codex/sessions not found (Codex not run yet)'); + return []; + } + + const maxAgeDays = options?.maxAgeDays ?? 30; + const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; + const cutoffMs = Date.now() - maxAgeMs; + + const results: SessionDescriptor[] = []; + + try { + // Level 1: year directories + const yearDirs = await readdir(sessionsPath); + + const yearPaths = (await Promise.all( + yearDirs.map(async (yearDir) => { + const yearPath = join(sessionsPath, yearDir); + return (await isDirectory(yearPath)) ? yearPath : null; + }) + )).filter((p): p is string => p !== null); + + await Promise.all(yearPaths.map(async (yearPath) => { + // Level 2: month directories + let monthDirs: string[]; + try { + monthDirs = await readdir(yearPath); + } catch { + return; + } + + const monthPaths = (await Promise.all( + monthDirs.map(async (monthDir) => { + const monthPath = join(yearPath, monthDir); + return (await isDirectory(monthPath)) ? monthPath : null; + }) + )).filter((p): p is string => p !== null); + + await Promise.all(monthPaths.map(async (monthPath) => { + // Level 3: day directories + let dayDirs: string[]; + try { + dayDirs = await readdir(monthPath); + } catch { + return; + } + + const dayPaths = (await Promise.all( + dayDirs.map(async (dayDir) => { + const dayPath = join(monthPath, dayDir); + return (await isDirectory(dayPath)) ? dayPath : null; + }) + )).filter((p): p is string => p !== null); + + await Promise.all(dayPaths.map(async (dayPath) => { + // Level 4: rollout files + let files: string[]; + try { + files = await readdir(dayPath); + } catch { + return; + } + + await Promise.all(files.map(async (file) => { + if (!file.endsWith('.jsonl')) return; + + const match = ROLLOUT_UUID_REGEX.exec(file); + if (!match) { + logger.debug(`[codex-discovery] Skipping file without UUID in name: ${file}`); + return; + } + + const sessionUuid = match[1]; + const filePath = join(dayPath, file); + + try { + const fileStat = await stat(filePath); + const mtime = fileStat.mtime.getTime(); + + // Age filter based on mtime (D-3) + if (mtime < cutoffMs) { + logger.debug(`[codex-discovery] Skipping old rollout: ${file}`); + return; + } + + results.push({ + sessionId: sessionUuid, + filePath, + createdAt: mtime, + agentName: 'codex', + }); + } catch { + logger.debug(`[codex-discovery] Could not stat file: ${filePath}`); + } + })); + })); + })); + })); + + // Sort by mtime descending (newest first) + results.sort((a, b) => b.createdAt - a.createdAt); + + // Apply limit + if (options?.limit && options.limit > 0) { + const limited = results.slice(0, options.limit); + logger.debug(`[codex-discovery] Found ${results.length} rollout files, returning ${limited.length} (limit: ${options.limit})`); + return limited; + } + + logger.debug(`[codex-discovery] Found ${results.length} rollout files`); + return results; + + } catch (error) { + logger.error('[codex-discovery] Failed to scan sessions directory:', error); + return []; + } + } + + /** + * Parse a Codex rollout JSONL file into ParsedSession format. + * + * Reads all records tolerantly, separates by type, extracts: + * - session_meta (once) → identity, cwd, git + * - turn_context (last one wins) → actual model + * - response_item (function_call / function_call_output) → preserved in messages + * - event_msg (user_message) → preserved in messages + */ + async parseSessionFile(filePath: string, sessionId: string): Promise { + try { + const records = await readCodexJsonlTolerant(filePath); + + if (records.length === 0) { + throw new ConfigurationError(`Rollout file is empty or unreadable: ${filePath}`); + } + + // Separate records by type + let sessionMeta: CodexSessionMeta | undefined; + let lastTurnContext: CodexTurnContext | undefined; + + for (const record of records) { + if (record.type === 'session_meta') { + sessionMeta = record.payload as CodexSessionMeta; + } else if (record.type === 'turn_context') { + // Last turn_context wins (D-2) + lastTurnContext = record.payload as CodexTurnContext; + } + // response_item and event_msg records are preserved in messages as-is + } + + if (!sessionMeta) { + throw new ConfigurationError(`No session_meta record found in rollout file: ${filePath}`); + } + + // Validate session_meta.id (D-6: recordId uses this UUID) + if (!sessionMeta.id || typeof sessionMeta.id !== 'string') { + throw new ConfigurationError(`session_meta.id is missing or invalid in rollout file: ${filePath}`); + } + + // Sanitize cwd before logging (security requirement) + logger.debug('[codex-adapter] Parsing rollout file', ...sanitizeLogArgs({ + sessionMetaId: sessionMeta.id, + recordCount: records.length, + })); + + // Resolve model: turn_context.model (primary) → session_meta.model_provider (fallback) (D-2) + const resolvedModel = lastTurnContext?.model?.trim() || sessionMeta.model_provider?.trim() || undefined; + + // Build ParsedSession + const metadata = { + projectPath: sessionMeta.cwd, + createdAt: sessionMeta.timestamp, + repository: sessionMeta.git?.repository_url, + branch: sessionMeta.git?.branch, + codexSessionId: sessionMeta.id, + model: resolvedModel, + cliVersion: sessionMeta.cli_version, + }; + + return { + sessionId, + agentName: 'Codex CLI', + agentVersion: sessionMeta.cli_version, + metadata, + messages: records, // Preserved in full for processors + metrics: { + tools: {}, + toolStatus: {}, + fileOperations: [], + }, + }; + + } catch (error) { + logger.error(`[codex-adapter] Failed to parse rollout file ${filePath}:`, error); + throw error; + } + } + + /** + * Process a Codex session file with all registered processors. + */ + async processSession( + filePath: string, + sessionId: string, + context: ProcessingContext + ): Promise { + try { + logger.debug(`[codex-adapter] Processing session ${sessionId} with ${this.processors.length} processors`); + + const parsedSession = await this.parseSessionFile(filePath, sessionId); + + const processorResults: Record = {}; + const failedProcessors: string[] = []; + let totalRecords = 0; + + for (const processor of this.processors) { + try { + if (!processor.shouldProcess(parsedSession)) { + logger.debug(`[codex-adapter] Processor ${processor.name} skipped`); + continue; + } + + logger.debug(`[codex-adapter] Running processor: ${processor.name}`); + const result = await processor.process(parsedSession, context); + + processorResults[processor.name] = { + success: result.success, + message: result.message, + recordsProcessed: result.metadata?.recordsProcessed as number | undefined, + }; + + if (!result.success) { + failedProcessors.push(processor.name); + logger.warn(`[codex-adapter] Processor ${processor.name} failed: ${result.message}`); + } + + const recordsProcessed = result.metadata?.recordsProcessed as number | undefined; + if (typeof recordsProcessed === 'number') { + totalRecords += recordsProcessed; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`[codex-adapter] Processor ${processor.name} threw:`, error); + processorResults[processor.name] = { success: false, message: errorMessage }; + failedProcessors.push(processor.name); + } + } + + return { + success: failedProcessors.length === 0, + processors: processorResults, + totalRecords, + failedProcessors, + }; + + } catch (error) { + logger.error('[codex-adapter] Session processing failed:', error); + throw error; + } + } +} + +/** Helper: return true if path is a directory */ +async function isDirectory(p: string): Promise { + if (!existsSync(p)) return false; + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} diff --git a/src/agents/plugins/codex/codex.storage-utils.ts b/src/agents/plugins/codex/codex.storage-utils.ts new file mode 100644 index 00000000..5f4687b4 --- /dev/null +++ b/src/agents/plugins/codex/codex.storage-utils.ts @@ -0,0 +1,20 @@ +// src/agents/plugins/codex/codex.storage-utils.ts +/** + * Codex Storage Utilities + * + * Tolerant JSONL reader for Codex rollout files. + * Delegates to the shared readJSONLTolerant utility (Critical #3 fix). + */ + +import { readJSONLTolerant } from '../../core/session/utils/jsonl-reader.js'; + +/** + * Tolerant JSONL reader for Codex rollout files. + * Skips corrupted lines instead of failing; logs count of skipped lines at warn level. + * + * @param filePath Absolute path to .jsonl rollout file + * @returns Array of parsed records (corrupted lines are skipped) + */ +export async function readCodexJsonlTolerant(filePath: string): Promise { + return readJSONLTolerant(filePath, '[codex-storage]'); +} diff --git a/src/agents/plugins/codex/index.ts b/src/agents/plugins/codex/index.ts new file mode 100644 index 00000000..250ffa24 --- /dev/null +++ b/src/agents/plugins/codex/index.ts @@ -0,0 +1,33 @@ +// src/agents/plugins/codex/index.ts +// Phase 1 exports (Core Plugin) +export { CodexPlugin, CodexPluginMetadata } from './codex.plugin.js'; + +// Phase 2 exports (Session Analytics) +export { CodexSessionAdapter } from './codex.session.js'; +export { + getCodexHomePath, + getCodexSessionsPath, + getCodexSessionDayPath, +} from './codex.paths.js'; + +// Types +export type { + CodexRolloutRecord, + CodexSessionMeta, + CodexTurnContext, + CodexResponseItem, + CodexEventMsg, + CodexSessionMetadata, +} from './codex-message-types.js'; + +// Type guards +export { + validateCodexMetadata, + hasCodexMetadata, +} from './codex-message-types.js'; + +// Discovery types +export type { + SessionDiscoveryOptions, + SessionDescriptor, +} from '../../core/session/discovery-types.js'; diff --git a/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts b/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts new file mode 100644 index 00000000..c137c5c2 --- /dev/null +++ b/src/agents/plugins/codex/session/processors/codex.conversations-processor.ts @@ -0,0 +1,74 @@ +// src/agents/plugins/codex/session/processors/codex.conversations-processor.ts +/** + * Codex Conversations Processor + * + * Normalises user messages and assistant responses from Codex rollout records + * into a unified conversation format. + * + * This processor is a placeholder for Phase 2 conversation sync. + * It runs and normalises data but does not write to the API in the initial delivery. + */ + +import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../../../core/session/BaseProcessor.js'; +import type { ParsedSession } from '../../../../core/session/BaseSessionAdapter.js'; +import type { CodexRolloutRecord, CodexResponseItem, CodexEventMsg } from '../../codex-message-types.js'; +import type { BaseNormalizedMessage } from '../../../../core/session/types.js'; +import { logger } from '../../../../../utils/logger.js'; + +export class CodexConversationsProcessor implements SessionProcessor { + readonly name = 'codex-conversations'; + readonly priority = 2; + + shouldProcess(session: ParsedSession): boolean { + return session.messages.length > 0; + } + + async process(session: ParsedSession, _context: ProcessingContext): Promise { + try { + const records = session.messages as CodexRolloutRecord[]; + const normalizedMessages: BaseNormalizedMessage[] = []; + + for (const record of records) { + if (record.type === 'event_msg') { + const event = record.payload as CodexEventMsg; + if (event.type === 'user_message' && event.message) { + normalizedMessages.push({ + role: 'user', + content: event.message, + }); + } + } else if (record.type === 'response_item') { + const item = record.payload as CodexResponseItem; + if (item.type === 'message' && item.output) { + normalizedMessages.push({ + role: 'assistant', + content: item.output, + }); + } + } + } + + logger.debug( + `[codex-conversations] Normalised ${normalizedMessages.length} messages` + ); + + return { + success: true, + message: `Normalised ${normalizedMessages.length} messages`, + metadata: { + recordsProcessed: normalizedMessages.length, + userMessages: normalizedMessages.filter(m => m.role === 'user').length, + assistantMessages: normalizedMessages.filter(m => m.role === 'assistant').length, + } + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[codex-conversations] Processing failed:', error); + return { + success: false, + message: `Conversations processing failed: ${errorMessage}`, + }; + } + } +} diff --git a/src/agents/plugins/codex/session/processors/codex.metrics-processor.ts b/src/agents/plugins/codex/session/processors/codex.metrics-processor.ts new file mode 100644 index 00000000..ac7750f3 --- /dev/null +++ b/src/agents/plugins/codex/session/processors/codex.metrics-processor.ts @@ -0,0 +1,159 @@ +// src/agents/plugins/codex/session/processors/codex.metrics-processor.ts +/** + * Codex Metrics Processor + * + * Extracts tool usage from Codex rollout JSONL records and writes a single + * MetricDelta per session via MetricsWriter. + * + * Design decisions: + * - One MetricDelta per session (D-6): Codex rollout files have no per-turn message IDs + * suitable for recordId, so session UUID from session_meta.id is used. + * - Tool pairing via call_id (D-1): function_call + function_call_output share a call_id. + * Output record present → success; absent → failure. + * - Token data omitted entirely (D-4): not present in rollout files. + * - Deduplication by recordId (session UUID): prevents duplicate writes on onSessionEnd reruns. + */ + +import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../../../core/session/BaseProcessor.js'; +import type { ParsedSession } from '../../../../core/session/BaseSessionAdapter.js'; +import type { MetricDelta } from '../../../../core/metrics/types.js'; +import type { + CodexRolloutRecord, + CodexResponseItem, + CodexSessionMetadata, +} from '../../codex-message-types.js'; +import { hasCodexMetadata } from '../../codex-message-types.js'; +import { readCodexJsonlTolerant } from '../../codex.storage-utils.js'; +import { logger } from '../../../../../utils/logger.js'; +import { sanitizeLogArgs } from '../../../../../utils/security.js'; + +export class CodexMetricsProcessor implements SessionProcessor { + readonly name = 'codex-metrics'; + readonly priority = 1; + + shouldProcess(session: ParsedSession): boolean { + return session.messages.length > 0; + } + + async process(session: ParsedSession, context: ProcessingContext): Promise { + try { + // Validate required metadata + if (!hasCodexMetadata(session.metadata)) { + return { + success: false, + message: 'Missing codexSessionId in session.metadata', + metadata: { failureReason: 'NO_CODEX_SESSION_ID' } + }; + } + + const meta = session.metadata as CodexSessionMetadata; + const codexSessionId = meta.codexSessionId; + + // Import MetricsWriter dynamically (mirrors OpenCode pattern) + const { MetricsWriter } = await import('../../../../../providers/plugins/sso/session/processors/metrics/MetricsWriter.js'); + const writer = new MetricsWriter(session.sessionId); + + // Deduplication: skip if this rollout file (recordId = codexSessionId) already processed + if (writer.exists()) { + const existingDeltas = await readCodexJsonlTolerant(writer.getFilePath()); + const existingIds = new Set(existingDeltas.map(d => d.recordId)); + if (existingIds.has(codexSessionId)) { + logger.debug(`[codex-metrics] Session ${codexSessionId} already processed, skipping`); + return { + success: true, + message: 'Session already processed (deduplication)', + metadata: { recordsProcessed: 0, deltasWritten: 0, skippedReason: 'ALREADY_PROCESSED' } + }; + } + } + + // Extract function_call and function_call_output records from pre-parsed messages + const records = session.messages as CodexRolloutRecord[]; + const functionCalls = new Map(); + const functionCallOutputs = new Map(); + + for (const record of records) { + if (record.type !== 'response_item') continue; + const item = record.payload as CodexResponseItem; + if (!item.call_id) continue; + + if (item.type === 'function_call') { + functionCalls.set(item.call_id, item); + } else if (item.type === 'function_call_output') { + functionCallOutputs.set(item.call_id, item); + } + } + + // Aggregate tool usage across all function_call records + const tools: Record = {}; + const toolStatus: Record = {}; + + for (const [callId, fc] of functionCalls) { + const toolName = (fc.name ?? 'unknown').toLowerCase(); + tools[toolName] = (tools[toolName] ?? 0) + 1; + + if (!toolStatus[toolName]) { + toolStatus[toolName] = { success: 0, failure: 0 }; + } + + // Output record present → success; absent → failure (D-1) + if (functionCallOutputs.has(callId)) { + toolStatus[toolName].success++; + } else { + toolStatus[toolName].failure++; + } + } + + // Resolve timestamp: session_meta.timestamp → Date.now() + let timestamp: number = Date.now(); + if (meta.createdAt) { + const parsed = new Date(meta.createdAt).getTime(); + if (!isNaN(parsed)) { + timestamp = parsed; + } + } + + // Build single MetricDelta for this session (D-6) + const delta: Omit = { + recordId: codexSessionId, + sessionId: session.sessionId, + agentSessionId: codexSessionId, + timestamp, + tools, + ...(Object.keys(toolStatus).length > 0 && { toolStatus }), + ...(meta.model ? { models: [meta.model] } : {}), + // tokens intentionally omitted (D-4) + }; + + logger.debug(`[codex-metrics] Writing delta ${codexSessionId}:`, ...sanitizeLogArgs({ + tools: Object.keys(tools), + toolCount: Object.keys(tools).length, + })); + + await writer.appendDelta(delta); + + logger.info(`[codex-metrics] Wrote 1 delta for session ${session.sessionId}`); + logger.info(`[codex-metrics] Metrics file: ${writer.getFilePath()}`); + + const _ = context; // context reserved for future API sync + + return { + success: true, + message: 'Generated 1 delta', + metadata: { + recordsProcessed: records.length, + deltasWritten: 1, + } + }; + + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + logger.error('[codex-metrics] Processing failed:', error); + return { + success: false, + message: `Metrics processing failed: ${errorMessage}`, + metadata: { failureReason: 'PROCESSING_ERROR' } + }; + } + } +} diff --git a/src/agents/plugins/opencode/opencode.storage-utils.ts b/src/agents/plugins/opencode/opencode.storage-utils.ts index 702465be..38c49b69 100644 --- a/src/agents/plugins/opencode/opencode.storage-utils.ts +++ b/src/agents/plugins/opencode/opencode.storage-utils.ts @@ -9,6 +9,7 @@ import { readFile } from 'fs/promises'; import { logger } from '../../../utils/logger.js'; +import { readJSONLTolerant } from '../../core/session/utils/jsonl-reader.js'; // Retry config per tech spec "F10 FIX": // - 1 initial read + 3 retries = 4 total read attempts @@ -74,32 +75,5 @@ export async function readJsonWithRetry( * @returns Array of parsed records (corrupted lines skipped) */ export async function readJsonlTolerant(filePath: string): Promise { - try { - const content = await readFile(filePath, 'utf-8'); - const lines = content.trim().split('\n'); - const results: T[] = []; - let corruptedCount = 0; - - for (const line of lines) { - if (!line.trim()) continue; - try { - results.push(JSON.parse(line) as T); - } catch { - corruptedCount++; - } - } - - if (corruptedCount > 0) { - logger.warn(`[opencode-storage] Skipped ${corruptedCount} corrupted JSONL lines in ${filePath}`); - } - return results; - } catch (error: unknown) { - const err = error as NodeJS.ErrnoException; - if (err.code === 'ENOENT') { - // File doesn't exist, return empty array - return []; - } - logger.debug(`[opencode-storage] Failed to read JSONL ${filePath}: ${err.message}`); - return []; - } + return readJSONLTolerant(filePath, '[opencode-storage]'); } diff --git a/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts b/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts index 96fa2d35..2708ee06 100644 --- a/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts +++ b/src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts @@ -1,6 +1,7 @@ // src/agents/plugins/opencode/session/processors/opencode.conversations-processor.ts import type { SessionProcessor, ProcessingContext, ProcessingResult } from '../../../../core/session/BaseProcessor.js'; import type { ParsedSession } from '../../../../core/session/BaseSessionAdapter.js'; +import type { BaseNormalizedMessage } from '../../../../core/session/types.js'; // FIXED (GPT-5.10/5.11): Import type guards from opencode-message-types.js instead of redefining // This removes duplicate function definitions that shadowed the imports import type { @@ -23,11 +24,9 @@ import { logger } from '../../../../../utils/logger.js'; * Normalized conversation message format * Aligns with CodeMie's conversation sync API */ -interface NormalizedMessage { +interface NormalizedMessage extends BaseNormalizedMessage { id: string; - role: 'user' | 'assistant'; - content: string; - timestamp: string; + timestamp: string; // non-optional override model?: string; agent?: string; toolUse?: Array<{ diff --git a/src/agents/registry.ts b/src/agents/registry.ts index 75fd82b8..e953287f 100644 --- a/src/agents/registry.ts +++ b/src/agents/registry.ts @@ -3,6 +3,7 @@ import { ClaudeAcpPlugin } from './plugins/claude/claude-acp.plugin.js'; import { CodeMieCodePlugin } from './plugins/codemie-code.plugin.js'; import { GeminiPlugin } from './plugins/gemini/gemini.plugin.js'; import { OpenCodePlugin } from './plugins/opencode/index.js'; +import { CodexPlugin } from './plugins/codex/index.js'; import { AgentAdapter, AgentAnalyticsAdapter } from './core/types.js'; // Re-export for backwards compatibility @@ -31,6 +32,7 @@ export class AgentRegistry { AgentRegistry.registerPlugin(new ClaudeAcpPlugin()); AgentRegistry.registerPlugin(new GeminiPlugin()); AgentRegistry.registerPlugin(new OpenCodePlugin()); + AgentRegistry.registerPlugin(new CodexPlugin()); AgentRegistry.initialized = true; } diff --git a/src/utils/config.ts b/src/utils/config.ts index e57205eb..e24bd569 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -202,8 +202,14 @@ export class ConfigLoader { } // Legacy single-provider config or partial config + // Only apply when no specific profile was requested (or requesting 'default'). + // If the caller explicitly selected a named profile (e.g. --profile lite-codex), + // the legacy local config is a different profile and must not contaminate it. if (isLegacyConfig(rawConfig)) { - return { ...rawConfig, name: 'default' }; + if (!profileName || profileName === 'default') { + return { ...rawConfig, name: 'default' }; + } + return {}; } // Empty or invalid config