import * as plugins from './plugins.js'; import type { IControllerMessageBundle, IControllerSession, IControllerRuntimeId, TControllerToolStatus } from '../ts_interfaces/index.js'; import { isCodexThreadId, isCodexSessionId } from './functions.codexidentity.js'; export type TCodexTerminalTurnStatus = 'completed' | 'interrupted' | 'failed'; export const codexRuntimeId = (nativeIdArg: string): IControllerRuntimeId & { harnessId: 'codex' } => ({ harnessId: 'codex', nativeId: nativeIdArg }); export const codexRecord = (valueArg: unknown): Record => { if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) throw new Error('Invalid Codex app-server object.'); return valueArg as Record; }; export const codexString = (valueArg: unknown, limitArg = 4096): string => { if (typeof valueArg !== 'string' || valueArg.length > limitArg) throw new Error('Invalid Codex app-server string.'); return valueArg; }; export const codexArray = (valueArg: unknown, limitArg: number): unknown[] => { if (!Array.isArray(valueArg) || valueArg.length > limitArg) throw new Error('Invalid Codex app-server list.'); return valueArg; }; export const codexThreadId = (valueArg: unknown): string => { if (!isCodexThreadId(valueArg)) throw new Error('Invalid Codex app-server thread ID.'); return valueArg; }; export const codexSessionId = (valueArg: unknown): string => { if (!isCodexSessionId(valueArg)) throw new Error('Invalid Codex conversation identity.'); return valueArg; }; export const codexTimestamp = (valueArg: unknown): number => { if (!Number.isSafeInteger(valueArg) || Number(valueArg) < 0 || !Number.isSafeInteger(Number(valueArg) * 1000)) throw new Error('Invalid Codex timestamp.'); return Number(valueArg) * 1000; }; /** A Codex text bounded to a byte budget, together with whether the bound actually cut it. */ export interface ICodexBoundedText { /** A complete-UTF-8 prefix of the original, carrying no marker of its own. */ text: string; truncated: boolean; } /** * Bound a Codex text to a byte budget and say what happened, so no caller has to read the result * back to find out. The live tool path needs exactly that: it reports the cut out of band, through * the contract's `outputTruncated` flag, because a marker inside the value would stop the durable * tool call from ever covering the live snapshot. `codexText` renders the same cut in band, which * is what the durable transcript projection keeps doing. */ export const codexBoundedText = (valueArg: unknown, maxBytesArg = 128 * 1024): ICodexBoundedText => { if (typeof valueArg !== 'string') return { text: '', truncated: false }; const bytes = Buffer.from(valueArg, 'utf8'); if (bytes.length <= maxBytesArg) return { text: valueArg, truncated: false }; // Keep a complete UTF-8 prefix. return { text: bytes.subarray(0, maxBytesArg).toString('utf8').replace(/\uFFFD$/, ''), truncated: true }; }; export const codexText = (valueArg: unknown, maxBytesArg = 128 * 1024): string => { const bounded = codexBoundedText(valueArg, maxBytesArg); return bounded.truncated ? `${bounded.text}\n[Output truncated]` : bounded.text; }; /** Cap for a derived one-line title; command actions carry arbitrary shell text. */ const maxCodexTitleBytes = 512; /** * Codex parses each command it runs into semantic actions — what its own TUI renders as * "Read SKILL.md". The `unknown` variant is always a lone entry standing for the whole command * line, which the card already shows verbatim, so it deliberately produces nothing. * * Note this is `commandActions` on the v2 app-server ThreadItem. The `parsed_cmd` spelling with * `cmd`/`list_files` belongs to the v1 rollout files, which AGL never reads. */ export const codexCommandTitle = (valueArg: unknown): string => { const actions = Array.isArray(valueArg) ? valueArg.slice(0, 8) : []; const parts: string[] = []; for (const entry of actions) { if (typeof entry !== 'object' || entry === null) continue; const action = entry as Record; const name = typeof action.name === 'string' ? action.name : ''; const path = typeof action.path === 'string' ? action.path : ''; const query = typeof action.query === 'string' ? action.query : ''; switch (action.type) { case 'read': if (name) parts.push(`Read ${name}`); break; case 'listFiles': parts.push(path ? `List ${path}` : 'List files'); break; case 'search': parts.push( query && path ? `Search ${query} in ${path}` : query ? `Search ${query}` : path ? `Search files in ${path}` : 'Search', ); break; default: // 'unknown' and any future variant: leave the command line to speak for itself. break; } } const unique = [...new Set(parts)]; return unique.length === 0 ? '' : codexText(unique.join(' · '), maxCodexTitleBytes).split('\n')[0]!; }; export const codexSession = (valueArg: unknown, directoryArg: string): IControllerSession => { const thread = codexRecord(valueArg); if (codexString(thread.cwd) !== directoryArg) throw new Error('Codex thread belongs to another project directory.'); const status = codexString(codexRecord(thread.status).type); if (!['active', 'idle', 'notLoaded', 'systemError'].includes(status)) throw new Error('Unknown Codex thread status.'); return { id: codexRuntimeId(codexSessionId(thread.id)), title: codexText(thread.name || thread.preview, 8192) || 'New Codex conversation', createdAt: codexTimestamp(thread.createdAt), updatedAt: codexTimestamp(thread.updatedAt), status: status === 'active' ? 'busy' : status === 'systemError' ? 'error' : 'idle', ...(typeof thread.parentThreadId === 'string' ? { parentId: codexRuntimeId(codexSessionId(thread.parentThreadId)) } : {}), }; }; /** * Native item statuses that mean the item can never change again — exactly the ones * `codexToolStatus` below maps to a settled controller status. A rollout carries one of these on * every item it has ever persisted, so a canonical item bearing one of them is authoritative and * outranks any live snapshot AGL is still holding. Anything else, including a status Codex may add * later, stays in progress on purpose: AGL keeps streaming it rather than settling it early. */ const codexTerminalItemStatuses: readonly unknown[] = ['completed', 'failed', 'declined', 'cancelled', 'interrupted']; export const isCodexTerminalItemStatus = (valueArg: unknown): boolean => codexTerminalItemStatuses.includes(valueArg); export const codexToolStatus = (valueArg: unknown, completedArg: boolean, terminalTurnStatusArg?: TCodexTerminalTurnStatus): TControllerToolStatus => { if (valueArg === 'cancelled' || valueArg === 'interrupted') return 'stopped'; if (valueArg === 'failed' || valueArg === 'declined') return 'error'; if (valueArg !== 'completed' && terminalTurnStatusArg === 'interrupted') return 'stopped'; if (valueArg !== 'completed' && terminalTurnStatusArg === 'failed') return 'error'; if (valueArg === 'completed' || completedArg) return 'completed'; return 'running'; }; /** One native item is one bundle, preserving app-server's paginated item ordering. */ export const codexItemBundle = ( itemArg: Record, turnIdArg: string, completedArg = true, terminalTurnStatusArg?: TCodexTerminalTurnStatus, ): IControllerMessageBundle => { const nativeId = codexString(itemArg.id, 512); const id = codexRuntimeId(nativeId); const type = codexString(itemArg.type, 128); // Turn IDs are UUIDv7; the timestamp is stable across pagination and reconnects. const createdAt = Number.parseInt(codexThreadId(turnIdArg).replaceAll('-', '').slice(0, 12), 16); const base = { id, createdAt, streaming: !completedArg }; let message: IControllerMessageBundle['messages'][number]; switch (type) { case 'userMessage': { const content = codexArray(itemArg.content, 1024).map(codexRecord); message = { ...base, role: 'user', text: codexText(content.map((part) => part.type === 'text' ? part.text : `[${String(part.type)}]`).join('\n')) }; break; } case 'agentMessage': case 'plan': message = { ...base, role: 'assistant', text: codexText(itemArg.text) }; break; case 'reasoning': message = { ...base, role: 'assistant', text: '', reasoning: [{ id, text: codexText(codexArray(itemArg.summary, 4096).join('\n') || codexArray(itemArg.content, 4096).join('\n')) }] }; break; case 'commandExecution': message = { ...base, role: 'tool', text: '', toolCall: { id, name: 'command', status: codexToolStatus(itemArg.status, completedArg, terminalTurnStatusArg), ...(codexCommandTitle(itemArg.commandActions) === '' ? {} : { title: codexCommandTitle(itemArg.commandActions) }), input: { command: codexText(itemArg.command, 48 * 1024), cwd: codexString(itemArg.cwd) }, output: codexText(itemArg.aggregatedOutput, 48 * 1024), // Every code Codex reports is relayed, including the negative one it uses for a process // that ended without an exit status. Dropping it left a failed command with no outcome to // show at all, while the shell's own signal terminations already arrive as 128 + signal. ...(Number.isSafeInteger(itemArg.exitCode) ? { exitCode: Number(itemArg.exitCode) } : {}), } }; break; default: { const name = type === 'mcpToolCall' ? `${codexString(itemArg.server)}.${codexString(itemArg.tool)}` : type === 'collabAgentToolCall' || type === 'dynamicToolCall' ? codexString(itemArg.tool) : type; const input = type === 'fileChange' ? { changes: codexArray(itemArg.changes, 128).map((value) => { const change = codexRecord(value); return { path: codexString(change.path), diff: codexText(change.diff, 48 * 1024), ...(change.kind === undefined ? {} : { kind: codexRecord(change.kind).type }) }; }) } : itemArg.arguments ?? itemArg.prompt ?? itemArg; const output = itemArg.result ?? itemArg.output ?? itemArg.contentItems ?? itemArg.error; message = { ...base, role: 'tool', text: '', toolCall: { id, name, status: codexToolStatus(itemArg.status, completedArg, terminalTurnStatusArg), input: type === 'fileChange' ? input : codexText(JSON.stringify(input), 48 * 1024), ...(output === undefined || output === null ? {} : { output: codexText(JSON.stringify(output), 48 * 1024) }), } }; } } return { sourceMessageId: id, messages: [message], structuralDigest: plugins.crypto.createHash('sha256').update(JSON.stringify(message)).digest('base64url') }; };