diff --git a/apps/codex-claw/src/routeTree.gen.ts b/apps/codex-claw/src/routeTree.gen.ts index 5f42d8a..2894c88 100644 --- a/apps/codex-claw/src/routeTree.gen.ts +++ b/apps/codex-claw/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ApiTasksRouteImport } from './routes/api/tasks' import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiSessionsRouteImport } from './routes/api/sessions' import { Route as ApiSendRouteImport } from './routes/api/send' +import { Route as ApiRunEventsRouteImport } from './routes/api/run-events' import { Route as ApiRepoContextRouteImport } from './routes/api/repo-context' import { Route as ApiPingRouteImport } from './routes/api/ping' import { Route as ApiPathsRouteImport } from './routes/api/paths' @@ -72,6 +73,11 @@ const ApiSendRoute = ApiSendRouteImport.update({ path: '/api/send', getParentRoute: () => rootRouteImport, } as any) +const ApiRunEventsRoute = ApiRunEventsRouteImport.update({ + id: '/api/run-events', + path: '/api/run-events', + getParentRoute: () => rootRouteImport, +} as any) const ApiRepoContextRoute = ApiRepoContextRouteImport.update({ id: '/api/repo-context', path: '/api/repo-context', @@ -125,6 +131,7 @@ export interface FileRoutesByFullPath { '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute + '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -144,6 +151,7 @@ export interface FileRoutesByTo { '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute + '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -164,6 +172,7 @@ export interface FileRoutesById { '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute '/api/repo-context': typeof ApiRepoContextRoute + '/api/run-events': typeof ApiRunEventsRoute '/api/send': typeof ApiSendRoute '/api/sessions': typeof ApiSessionsRoute '/api/stream': typeof ApiStreamRoute @@ -185,6 +194,7 @@ export interface FileRouteTypes { | '/api/paths' | '/api/ping' | '/api/repo-context' + | '/api/run-events' | '/api/send' | '/api/sessions' | '/api/stream' @@ -204,6 +214,7 @@ export interface FileRouteTypes { | '/api/paths' | '/api/ping' | '/api/repo-context' + | '/api/run-events' | '/api/send' | '/api/sessions' | '/api/stream' @@ -223,6 +234,7 @@ export interface FileRouteTypes { | '/api/paths' | '/api/ping' | '/api/repo-context' + | '/api/run-events' | '/api/send' | '/api/sessions' | '/api/stream' @@ -243,6 +255,7 @@ export interface RootRouteChildren { ApiPathsRoute: typeof ApiPathsRoute ApiPingRoute: typeof ApiPingRoute ApiRepoContextRoute: typeof ApiRepoContextRoute + ApiRunEventsRoute: typeof ApiRunEventsRoute ApiSendRoute: typeof ApiSendRoute ApiSessionsRoute: typeof ApiSessionsRoute ApiStreamRoute: typeof ApiStreamRoute @@ -316,6 +329,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSendRouteImport parentRoute: typeof rootRouteImport } + '/api/run-events': { + id: '/api/run-events' + path: '/api/run-events' + fullPath: '/api/run-events' + preLoaderRoute: typeof ApiRunEventsRouteImport + parentRoute: typeof rootRouteImport + } '/api/repo-context': { id: '/api/repo-context' path: '/api/repo-context' @@ -387,6 +407,7 @@ const rootRouteChildren: RootRouteChildren = { ApiPathsRoute: ApiPathsRoute, ApiPingRoute: ApiPingRoute, ApiRepoContextRoute: ApiRepoContextRoute, + ApiRunEventsRoute: ApiRunEventsRoute, ApiSendRoute: ApiSendRoute, ApiSessionsRoute: ApiSessionsRoute, ApiStreamRoute: ApiStreamRoute, diff --git a/apps/codex-claw/src/routes/api/run-events.ts b/apps/codex-claw/src/routes/api/run-events.ts new file mode 100644 index 0000000..baac003 --- /dev/null +++ b/apps/codex-claw/src/routes/api/run-events.ts @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { getCodexRunEventLog } from '../../server/codex-cli' + +function downloadName(id: string) { + return 'codexclaw-run-' + id.replace(/[^a-zA-Z0-9._-]/g, '-') + '.json' +} + +export const Route = createFileRoute('/api/run-events')({ + server: { + handlers: { + GET: ({ request }) => { + try { + const url = new URL(request.url) + const id = url.searchParams.get('id') ?? '' + const payload = getCodexRunEventLog({ id }) + if (url.searchParams.get('download') === '1') { + return new Response(JSON.stringify(payload, null, 2), { + headers: { + 'content-type': 'application/json; charset=utf-8', + 'content-disposition': + 'attachment; filename="' + downloadName(id) + '"', + }, + }) + } + return json(payload) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 404 }, + ) + } + }, + }, + }, +}) diff --git a/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx b/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx index a86febe..3e581a8 100644 --- a/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx +++ b/apps/codex-claw/src/screens/chat/components/task-queue-panel.tsx @@ -3,14 +3,21 @@ import { HugeiconsIcon } from '@hugeicons/react' import { Cancel01Icon, CheckmarkCircle01Icon, + Download01Icon, RefreshIcon, + TimelineIcon, } from '@hugeicons/core-free-icons' import { cancelCodexTask, fetchCodexTasks, retryCodexTask, } from '../chat-queries' -import type { CodexTaskRecord, CodexTaskStatus } from '../types' +import type { + CodexRunTimelineEvent, + CodexRunTokenMetrics, + CodexTaskRecord, + CodexTaskStatus, +} from '../types' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -49,6 +56,65 @@ function formatDuration(task: CodexTaskRecord) { return String(minutes) + 'm ' + String(remainder) + 's' } +function formatMs(duration: number) { + if (duration < 1000) return String(Math.max(0, Math.round(duration))) + 'ms' + const seconds = duration / 1000 + if (seconds < 60) return seconds.toFixed(seconds < 10 ? 1 : 0) + 's' + const minutes = Math.floor(seconds / 60) + const remainder = Math.round(seconds % 60) + return String(minutes) + 'm ' + String(remainder) + 's' +} + +function tokenMetricLabel(metrics?: CodexRunTokenMetrics) { + if (!metrics) return 'Token/context metrics unavailable' + const parts: Array = [] + if (typeof metrics.totalTokens === 'number') { + parts.push(metrics.totalTokens.toLocaleString() + ' total') + } + if (typeof metrics.inputTokens === 'number') { + parts.push(metrics.inputTokens.toLocaleString() + ' in') + } + if (typeof metrics.outputTokens === 'number') { + parts.push(metrics.outputTokens.toLocaleString() + ' out') + } + if (typeof metrics.contextTokens === 'number') { + parts.push(metrics.contextTokens.toLocaleString() + ' context') + } + return parts.length > 0 + ? parts.join(' · ') + : 'Token/context metrics unavailable' +} + +function eventClass(kind: CodexRunTimelineEvent['kind']) { + if (kind === 'error') return 'bg-red-100 text-red-700' + if (kind === 'exit' || kind === 'final-message') { + return 'bg-emerald-100 text-emerald-700' + } + if (kind === 'tool-call' || kind === 'tool-result') { + return 'bg-blue-100 text-blue-700' + } + return 'bg-primary-100 text-primary-700' +} + +function eventMeta(event: CodexRunTimelineEvent) { + const parts: Array = [] + if (event.commandName) parts.push(event.commandName) + if (typeof event.exitCode === 'number') { + parts.push('exit ' + String(event.exitCode)) + } else if (event.exitCode === null) { + parts.push('exit unavailable') + } + if (event.status) parts.push(event.status) + return parts.join(' · ') +} + +function exportRunEvents(task: CodexTaskRecord) { + if (typeof window === 'undefined') return + const url = + '/api/run-events?id=' + encodeURIComponent(task.id) + '&download=1' + window.location.assign(url) +} + function canCancel(task: CodexTaskRecord) { return task.status === 'queued' || task.status === 'running' } @@ -184,6 +250,14 @@ export function TaskQueuePanel({ open, onClose }: TaskQueuePanelProps) { {typeof task.exitCode === 'number' ? task.exitCode : 'open'} +
+ + {task.timeline.length.toLocaleString()} timeline events + + + {tokenMetricLabel(task.tokenMetrics)} + +
{task.error ? (
{task.error} @@ -206,6 +280,74 @@ export function TaskQueuePanel({ open, onClose }: TaskQueuePanelProps) { > Retry + +
+ +
+
+ + + Run timeline + + + final status: {statusLabel(task.status)} + +
+
+ {task.timeline.length === 0 ? ( +
+ No normalized run events recorded for this run. +
+ ) : null} + {task.timeline.map((event) => ( +
+
+ + +{formatMs(event.relativeMs)} + +
+
+ + {event.label} + + {eventMeta(event) ? ( + + {eventMeta(event)} + + ) : null} +
+ {event.message ? ( +
+ {event.message} +
+ ) : null} +
+
+
+ ))}
diff --git a/apps/codex-claw/src/screens/chat/types.ts b/apps/codex-claw/src/screens/chat/types.ts index bf30fe6..1cafd7e 100644 --- a/apps/codex-claw/src/screens/chat/types.ts +++ b/apps/codex-claw/src/screens/chat/types.ts @@ -286,6 +286,39 @@ export type CodexTaskEvent = { note?: string } +export type CodexRunEventKind = + | 'prompt-start' + | 'assistant-delta' + | 'tool-call' + | 'tool-result' + | 'final-message' + | 'exit' + | 'error' + | 'status' + +export type CodexRunTokenMetrics = { + inputTokens?: number + outputTokens?: number + totalTokens?: number + contextTokens?: number + raw?: Record +} + +export type CodexRunTimelineEvent = { + id: string + kind: CodexRunEventKind + at: number + relativeMs: number + label: string + message?: string + commandName?: string + toolCallId?: string + exitCode?: number | null + status?: string + tokenMetrics?: CodexRunTokenMetrics + details?: Record +} + export type CodexTaskRecord = { id: string sessionKey: string @@ -302,6 +335,8 @@ export type CodexTaskRecord = { error?: string retryOf?: string events: Array + timeline: Array + tokenMetrics?: CodexRunTokenMetrics } export type TaskListResponse = { diff --git a/apps/codex-claw/src/server/codex-cli.test.ts b/apps/codex-claw/src/server/codex-cli.test.ts index d5f5769..a5a2e7e 100644 --- a/apps/codex-claw/src/server/codex-cli.test.ts +++ b/apps/codex-claw/src/server/codex-cli.test.ts @@ -8,6 +8,7 @@ import { deleteCodexWorkspace, getCodexArtifactFile, getCodexPaths, + getCodexRunEventLog, listCodexArtifacts, listCodexSessions, listCodexWorkspaces, @@ -359,4 +360,71 @@ describe('codex workspace registry', function () { }).content.toString('utf8'), ).toBe('command output') }) + + it('exports redacted run timeline events and token metrics', function () { + const paths = getCodexPaths() + mkdirSync(paths.stateDir, { recursive: true }) + writeFileSync( + path.join(paths.stateDir, 'tasks.json'), + JSON.stringify({ + version: 1, + tasks: [ + { + id: 'run-1', + sessionKey: 'session-1', + messageId: 'message-1', + prompt: 'private prompt', + message: 'private message', + status: 'completed', + createdAt: 100, + updatedAt: 200, + startedAt: 120, + finishedAt: 200, + durationMs: 80, + exitCode: 0, + snapshot: { + sessionKey: 'session-1', + message: 'private message', + }, + events: [{ status: 'completed', at: 200 }], + tokenMetrics: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + timeline: [ + { + id: 'event-1', + kind: 'tool-call', + at: 150, + relativeMs: 30, + label: 'Tool call', + commandName: path.join(tempDir, 'run-tests.ps1'), + details: { + cwd: tempDir, + }, + }, + ], + }, + ], + }), + ) + resetCodexServerStateForTests() + + const log = getCodexRunEventLog({ id: 'run-1' }) + + expect(log).toMatchObject({ + runId: 'run-1', + status: 'completed', + tokenMetricsAvailable: true, + tokenMetrics: { + totalTokens: 15, + }, + }) + expect(log).not.toHaveProperty('prompt') + expect(log.events[0].commandName).toBe( + '$WORKSPACE' + path.sep + 'run-tests.ps1', + ) + expect(log.events[0].details).toEqual({ cwd: '$WORKSPACE' }) + }) }) diff --git a/apps/codex-claw/src/server/codex-cli.ts b/apps/codex-claw/src/server/codex-cli.ts index 615aa27..af9a482 100644 --- a/apps/codex-claw/src/server/codex-cli.ts +++ b/apps/codex-claw/src/server/codex-cli.ts @@ -117,6 +117,39 @@ type CodexTaskEvent = { note?: string } +type CodexRunEventKind = + | 'prompt-start' + | 'assistant-delta' + | 'tool-call' + | 'tool-result' + | 'final-message' + | 'exit' + | 'error' + | 'status' + +type CodexRunTokenMetrics = { + inputTokens?: number + outputTokens?: number + totalTokens?: number + contextTokens?: number + raw?: Record +} + +type CodexRunTimelineEvent = { + id: string + kind: CodexRunEventKind + at: number + relativeMs: number + label: string + message?: string + commandName?: string + toolCallId?: string + exitCode?: number | null + status?: string + tokenMetrics?: CodexRunTokenMetrics + details?: Record +} + type CodexTaskSnapshot = { sessionKey: string message: string @@ -145,6 +178,8 @@ type CodexTaskRecord = { retryOf?: string snapshot: CodexTaskSnapshot events: Array + timeline: Array + tokenMetrics?: CodexRunTokenMetrics } type TaskStore = { @@ -711,6 +746,98 @@ function normalizeTaskStatus(value: string): CodexTaskStatus { return 'failed' } +function normalizeRunEventKind(value: unknown): CodexRunEventKind { + if ( + value === 'prompt-start' || + value === 'assistant-delta' || + value === 'tool-call' || + value === 'tool-result' || + value === 'final-message' || + value === 'exit' || + value === 'error' || + value === 'status' + ) { + return value + } + return 'status' +} + +function numericMetric(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : undefined +} + +function normalizeTokenMetrics( + value: unknown, +): CodexRunTokenMetrics | undefined { + if (!value || typeof value !== 'object') return undefined + const record = value as Record + const metrics: CodexRunTokenMetrics = {} + metrics.inputTokens = + numericMetric(record.input_tokens) ?? + numericMetric(record.prompt_tokens) ?? + numericMetric(record.inputTokens) ?? + numericMetric(record.promptTokens) + metrics.outputTokens = + numericMetric(record.output_tokens) ?? + numericMetric(record.completion_tokens) ?? + numericMetric(record.outputTokens) ?? + numericMetric(record.completionTokens) + metrics.totalTokens = + numericMetric(record.total_tokens) ?? + numericMetric(record.totalTokens) ?? + (typeof metrics.inputTokens === 'number' && + typeof metrics.outputTokens === 'number' + ? metrics.inputTokens + metrics.outputTokens + : undefined) + metrics.contextTokens = + numericMetric(record.context_tokens) ?? + numericMetric(record.context_window) ?? + numericMetric(record.context_window_tokens) ?? + numericMetric(record.contextTokens) ?? + numericMetric(record.contextWindow) + + const hasMetrics = + typeof metrics.inputTokens === 'number' || + typeof metrics.outputTokens === 'number' || + typeof metrics.totalTokens === 'number' || + typeof metrics.contextTokens === 'number' + if (!hasMetrics) return undefined + metrics.raw = record + return metrics +} + +function normalizeTimelineEvent( + event: CodexRunTimelineEvent, +): CodexRunTimelineEvent { + return { + id: typeof event.id === 'string' ? event.id : randomUUID(), + kind: normalizeRunEventKind(event.kind), + at: typeof event.at === 'number' ? event.at : Date.now(), + relativeMs: + typeof event.relativeMs === 'number' && Number.isFinite(event.relativeMs) + ? Math.max(0, Math.floor(event.relativeMs)) + : 0, + label: typeof event.label === 'string' ? event.label : 'Run event', + message: typeof event.message === 'string' ? event.message : undefined, + commandName: + typeof event.commandName === 'string' ? event.commandName : undefined, + toolCallId: + typeof event.toolCallId === 'string' ? event.toolCallId : undefined, + exitCode: + typeof event.exitCode === 'number' || event.exitCode === null + ? event.exitCode + : undefined, + status: typeof event.status === 'string' ? event.status : undefined, + tokenMetrics: normalizeTokenMetrics(event.tokenMetrics), + details: + event.details && typeof event.details === 'object' + ? (event.details) + : undefined, + } +} + function normalizeTaskRecord(value: CodexTaskRecord): CodexTaskRecord { const status = normalizeTaskStatus(value.status) return { @@ -723,6 +850,10 @@ function normalizeTaskRecord(value: CodexTaskRecord): CodexTaskRecord { at: event.at, note: typeof event.note === 'string' ? event.note : undefined, })), + timeline: Array.isArray(value.timeline) + ? value.timeline.map(normalizeTimelineEvent) + : [], + tokenMetrics: normalizeTokenMetrics(value.tokenMetrics), } } @@ -786,7 +917,9 @@ function readArtifactStore() { if (artifactStoreCache) return artifactStoreCache const artifactStorePath = getArtifactStorePath() try { - const parsed = JSON.parse(readFileSync(artifactStorePath, 'utf8')) as unknown + const parsed = JSON.parse( + readFileSync(artifactStorePath, 'utf8'), + ) as unknown artifactStoreCache = isArtifactStore(parsed) ? { version: 1, @@ -863,6 +996,77 @@ function updateTask( return task } +function timelineMessage(value: string) { + const normalized = value.replace(/\s+/g, ' ').trim() + if (normalized.length <= 500) return normalized + return normalized.slice(0, 497) + '...' +} + +function eventRelativeMs(task: CodexTaskRecord, at: number) { + return Math.max(0, at - (task.startedAt ?? task.createdAt)) +} + +function appendRunEvent( + taskId: string, + input: Omit & { + at?: number + }, +) { + const store = readTaskStore() + const task = store.tasks.find((item) => item.id === taskId) + if (!task) return null + const at = input.at ?? Date.now() + const event: CodexRunTimelineEvent = { + id: randomUUID(), + kind: input.kind, + at, + relativeMs: eventRelativeMs(task, at), + label: input.label, + message: + typeof input.message === 'string' + ? timelineMessage(input.message) + : undefined, + commandName: input.commandName, + toolCallId: input.toolCallId, + exitCode: input.exitCode, + status: input.status, + tokenMetrics: input.tokenMetrics, + details: input.details, + } + task.timeline.push(event) + if (task.timeline.length > 300) { + task.timeline = task.timeline.slice(task.timeline.length - 300) + } + if (input.tokenMetrics) task.tokenMetrics = input.tokenMetrics + task.updatedAt = at + writeTaskStore(store) + return event +} + +function appendStatusRunEvent( + taskId: string, + status: string, + label: string, + details?: Record, +) { + return appendRunEvent(taskId, { + kind: 'status', + label, + status, + details, + }) +} + +function appendTokenMetrics(taskId: string, metrics?: CodexRunTokenMetrics) { + if (!metrics) return + appendRunEvent(taskId, { + kind: 'status', + label: 'Token metrics captured', + status: 'metrics', + tokenMetrics: metrics, + }) +} + function createTaskRecord(input: { id: string sessionKey: string @@ -886,6 +1090,21 @@ function createTaskRecord(input: { retryOf: input.retryOf, snapshot: input.snapshot, events: [{ status: 'queued', at: now }], + timeline: [ + { + id: randomUUID(), + kind: 'prompt-start', + at: now, + relativeMs: 0, + label: 'Prompt queued', + status: 'queued', + details: { + runProfile: input.runProfile, + hasAttachments: Boolean(input.snapshot.attachments?.length), + hasContext: Boolean(input.snapshot.contextBlock?.trim()), + }, + }, + ], } writeTask(task) return task @@ -1106,7 +1325,11 @@ function sanitizeArtifactSegment(value: string) { function isPathInside(parent: string, child: string) { const relative = path.relative(parent, child) - return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative) + return ( + Boolean(relative) && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ) } function redactArtifactPath(filePath: string) { @@ -1168,7 +1391,9 @@ function artifactId(sessionKey: string, artifactPath: string, runId?: string) { function upsertArtifact(record: CodexArtifactRecord) { const store = readArtifactStore() - const index = store.artifacts.findIndex((artifact) => artifact.id === record.id) + const index = store.artifacts.findIndex( + (artifact) => artifact.id === record.id, + ) if (index >= 0) { store.artifacts[index] = record } else { @@ -1609,43 +1834,66 @@ function processCommandExecutionJsonEvent( return null } -export function processCodexJsonLine( - line: string, -): ProcessedCodexJsonLine | null { +function parseCodexJsonEventLine(line: string): CodexExecJsonEvent | null { if (!line.startsWith('{')) return null try { - const event = JSON.parse(line) as CodexExecJsonEvent - const commandEvent = processCommandExecutionJsonEvent(event) - if (commandEvent) return commandEvent - if (!isAssistantJsonEvent(event)) return null + return JSON.parse(line) as CodexExecJsonEvent + } catch { + return null + } +} - const text = - extractTextCandidate(event.item) || - extractTextCandidate(event) || - extractTextFromContent(event.content) - - if (!text) return null - - if ( - event.type === 'item.completed' || - event.type === 'response.output_text.done' || - event.type === 'assistant.completed' - ) { - return { kind: 'assistant-final', text } - } +function usageFromCodexEvent( + event: CodexExecJsonEvent, +): CodexRunTokenMetrics | undefined { + return ( + normalizeTokenMetrics(event.usage) ?? + normalizeTokenMetrics( + event.item && typeof event.item === 'object' + ? (event.item as Record).usage + : undefined, + ) + ) +} - if ( - event.type?.includes('delta') || - event.type?.includes('updated') || - event.type?.includes('stream') - ) { - return { kind: 'assistant-delta', text } - } +function processCodexJsonEvent( + event: CodexExecJsonEvent, +): ProcessedCodexJsonLine | null { + const commandEvent = processCommandExecutionJsonEvent(event) + if (commandEvent) return commandEvent + if (!isAssistantJsonEvent(event)) return null - return null - } catch { - return null + const text = + extractTextCandidate(event.item) || + extractTextCandidate(event) || + extractTextFromContent(event.content) + + if (!text) return null + + if ( + event.type === 'item.completed' || + event.type === 'response.output_text.done' || + event.type === 'assistant.completed' + ) { + return { kind: 'assistant-final', text } } + + if ( + event.type?.includes('delta') || + event.type?.includes('updated') || + event.type?.includes('stream') + ) { + return { kind: 'assistant-delta', text } + } + + return null +} + +export function processCodexJsonLine( + line: string, +): ProcessedCodexJsonLine | null { + const event = parseCodexJsonEventLine(line) + return event ? processCodexJsonEvent(event) : null } export function mergeAssistantText( @@ -1686,6 +1934,9 @@ function runCodexExec( }) runningTasks.set(runId, child) updateTask(runId, 'running') + appendStatusRunEvent(runId, 'running', 'Codex CLI started', { + command: path.basename(command), + }) let stdoutBuffer = '' let stderrBuffer = '' @@ -1721,6 +1972,12 @@ function runCodexExec( const message = messageFromAssistantText(text, runId) appendMessage(session, message) writeStore(store) + appendRunEvent(runId, { + kind: 'final-message', + label: 'Final assistant message', + message: text, + status: 'final', + }) emittedFinal = true emitFinalMessage(message) } @@ -1731,11 +1988,44 @@ function runCodexExec( const lines = stdoutBuffer.split(/\r?\n/) stdoutBuffer = lines.pop() ?? '' for (const line of lines) { - const processed = processCodexJsonLine(line.trim()) + const event = parseCodexJsonEventLine(line.trim()) + if (!event) continue + appendTokenMetrics(runId, usageFromCodexEvent(event)) + const processed = processCodexJsonEvent(event) if (!processed) continue if (processed.kind === 'message-delta') { appendMessage(session, processed.message) writeStore(store) + const toolCall = processed.message.content?.find( + (part) => part.type === 'toolCall', + ) + const details = processed.message.details ?? {} + const commandName = + typeof details.command === 'string' + ? details.command + : typeof toolCall?.name === 'string' + ? toolCall.name + : processed.message.toolName + appendRunEvent(runId, { + kind: + processed.message.role === 'toolResult' + ? 'tool-result' + : 'tool-call', + label: + processed.message.role === 'toolResult' + ? 'Tool result' + : 'Tool call', + commandName, + toolCallId: processed.message.toolCallId ?? toolCall?.id, + exitCode: + typeof details.exitCode === 'number' ? details.exitCode : undefined, + status: + typeof details.status === 'string' ? details.status : undefined, + message: + processed.message.role === 'toolResult' + ? extractTextFromContent(processed.message.content) + : undefined, + }) emitStreamMessage(processed.message, 'delta') continue } @@ -1745,6 +2035,12 @@ function runCodexExec( processed.kind, ) if (processed.kind === 'assistant-delta') { + appendRunEvent(runId, { + kind: 'assistant-delta', + label: 'Assistant delta', + message: assistantText, + status: 'streaming', + }) emitAssistantDelta(assistantText) continue } @@ -1766,6 +2062,12 @@ function runCodexExec( child.on('error', (error) => { preparedAttachments.cleanup() runningTasks.delete(runId) + appendRunEvent(runId, { + kind: 'error', + label: 'Process error', + message: error.message, + status: 'failed', + }) updateTask(runId, 'failed', { note: error.message, exitCode: null, @@ -1790,6 +2092,12 @@ function runCodexExec( runningTasks.delete(runId) if (currentTaskStatus() === 'canceled') { updateTask(runId, 'canceled', { exitCode: code }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Run canceled', + exitCode: code, + status: 'canceled', + }) emit(session.key, { event: 'chat', stateVersion, @@ -1801,7 +2109,10 @@ function runCodexExec( }) return } - const trailing = processCodexJsonLine(stdoutBuffer.trim()) + const trailingEvent = parseCodexJsonEventLine(stdoutBuffer.trim()) + if (trailingEvent) + appendTokenMetrics(runId, usageFromCodexEvent(trailingEvent)) + const trailing = trailingEvent ? processCodexJsonEvent(trailingEvent) : null if (trailing) { if (trailing.kind === 'message-delta') { appendMessage(session, trailing.message) @@ -1819,10 +2130,22 @@ function runCodexExec( ) { appendFinalText(assistantText) updateTask(runId, 'completed', { exitCode: code }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Codex CLI exited', + exitCode: code, + status: 'completed', + }) return } if (emittedFinal && code === 0) { updateTask(runId, 'completed', { exitCode: code }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Codex CLI exited', + exitCode: code, + status: 'completed', + }) return } } @@ -1830,11 +2153,23 @@ function runCodexExec( if (emittedFinal && code === 0) { updateTask(runId, 'completed', { exitCode: code }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Codex CLI exited', + exitCode: code, + status: 'completed', + }) return } if (code === 0 && assistantText) { appendFinalText(assistantText) updateTask(runId, 'completed', { exitCode: code }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Codex CLI exited', + exitCode: code, + status: 'completed', + }) return } @@ -1848,6 +2183,19 @@ function runCodexExec( note: taskErrorDetail, exitCode: code, }) + appendRunEvent(runId, { + kind: 'error', + label: 'Run failed', + message: taskErrorDetail, + exitCode: code, + status: 'failed', + }) + appendRunEvent(runId, { + kind: 'exit', + label: 'Codex CLI exited', + exitCode: code, + status: 'failed', + }) const fallback = code === 0 ? 'Codex CLI finished without returning an assistant message.' @@ -2425,6 +2773,87 @@ function publicTaskRecord(task: CodexTaskRecord) { error: task.error, retryOf: task.retryOf, events: task.events, + timeline: task.timeline, + tokenMetrics: task.tokenMetrics, + } +} + +function redactEventString(value: string) { + const replacements = [ + [path.resolve(getStateDir()), '$CODEX_CLAW_STATE'], + [path.resolve(getCodexWorkdir()), '$WORKSPACE'], + [os.homedir(), '~'], + ] as const + let next = value + for (const [from, to] of replacements) { + if (!from) continue + next = next.split(from).join(to) + } + return next +} + +function redactEventValue(value: unknown): unknown { + if (typeof value === 'string') return redactEventString(value) + if ( + typeof value === 'number' || + typeof value === 'boolean' || + value === null + ) { + return value + } + if (Array.isArray(value)) return value.map(redactEventValue) + if (!value || typeof value !== 'object') return undefined + const redacted: Record = {} + for (const [key, entry] of Object.entries(value)) { + const nextValue = redactEventValue(entry) + if (typeof nextValue !== 'undefined') redacted[key] = nextValue + } + return redacted +} + +function redactRunEvent(event: CodexRunTimelineEvent) { + return { + ...event, + message: + typeof event.message === 'string' + ? redactEventString(event.message) + : undefined, + commandName: + typeof event.commandName === 'string' + ? redactEventString(event.commandName) + : undefined, + details: redactEventValue(event.details) as + | Record + | undefined, + tokenMetrics: event.tokenMetrics + ? { + ...event.tokenMetrics, + raw: redactEventValue(event.tokenMetrics.raw) as + | Record + | undefined, + } + : undefined, + } +} + +export function getCodexRunEventLog(input: { id: string }) { + const id = input.id.trim() + const task = readTaskStore().tasks.find((item) => item.id === id) + if (!task) throw new Error('Run not found.') + return { + exportedAt: new Date().toISOString(), + runId: task.id, + sessionKey: task.sessionKey, + messageId: task.messageId, + status: task.status, + createdAt: task.createdAt, + startedAt: task.startedAt, + finishedAt: task.finishedAt, + durationMs: task.durationMs, + exitCode: task.exitCode, + tokenMetrics: task.tokenMetrics ?? null, + tokenMetricsAvailable: Boolean(task.tokenMetrics), + events: task.timeline.map(redactRunEvent), } } @@ -2466,8 +2895,8 @@ export function listCodexArtifacts(input: { sessionKeys.add(key) sessionKeys.add(deriveFriendlyIdFromKey(key)) } - const artifacts = readArtifactStore().artifacts - .filter((artifact) => { + const artifacts = readArtifactStore() + .artifacts.filter((artifact) => { if (sessionKeys.size === 0) return true return sessionKeys.has(artifact.sessionKey) })