From f9869db75a19de6251830dbb938bafcd9928845a Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Tue, 4 Aug 2026 17:52:53 +0400 Subject: [PATCH 1/6] fix(assistants): fix silent zero-attachment bug in claude session upload detection Replace broken two-pass buildAttachmentMap (wrong JSONL structure assumption) and RECENT_MESSAGES_LIMIT=2 (too narrow for real sessions with tool-result messages) with a turn-boundary backward scan that stops at the most recent assistant message. Real Claude Code JSONL has isMeta=true messages carrying both base64 attachment data and [Image: source: /path] filename text in the same message object. The old code expected base64 in a non-meta parent and filename text in a meta child, so the attachment map was never populated and zero files were returned. The scan window of 2 also broke in real sessions where tool-result messages at positions 1-2 pushed the image meta message to position 3, outside the window. EPMCDME-13907 --- .../__tests__/claudeUploadsDetector.test.ts | 260 +++++++++++++----- .../assistants/chat/claudeUploadsDetector.ts | 102 ++----- 2 files changed, 219 insertions(+), 143 deletions(-) diff --git a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts index b6897e2e7..58dff3232 100644 --- a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts +++ b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts @@ -184,13 +184,13 @@ describe('fileResolver', () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; const messages: ClaudeMessage[] = [ - // Meta message with file name + // Real Claude Code JSONL: meta message holds BOTH base64 and [Image: source:] text { type: 'user', uuid: 'meta-1', - parentUuid: 'msg-1', + parentUuid: 'msg-parent', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:00Z', + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -198,22 +198,6 @@ describe('fileResolver', () => { { type: 'text', text: '[Image: source: /path/to/screenshot.png]' - } - ] - } - } as ClaudeMessage, - // User message with image - { - type: 'user', - uuid: 'msg-1', - sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', - message: { - role: 'user', - content: [ - { - type: 'text', - text: 'Look at this image' }, { type: 'image', @@ -225,6 +209,17 @@ describe('fileResolver', () => { } ] } + } as ClaudeMessage, + // Parent non-meta message — empty, as in real Claude Code JSONL + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { + role: 'user', + content: [] + } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); @@ -253,13 +248,13 @@ describe('fileResolver', () => { vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); const messages: ClaudeMessage[] = [ - // Meta message with multiple file names + // Single meta message with filename text AND both attachment content items { type: 'user', uuid: 'meta-1', - parentUuid: 'msg-1', + parentUuid: 'msg-parent', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:00Z', + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -267,19 +262,7 @@ describe('fileResolver', () => { { type: 'text', text: '[Image: source: /path/to/image1.png]\n[Document: source: /path/to/doc.pdf]' - } - ] - } - } as ClaudeMessage, - // User message with multiple attachments - { - type: 'user', - uuid: 'msg-1', - sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', - message: { - role: 'user', - content: [ + }, { type: 'image', source: { @@ -298,6 +281,13 @@ describe('fileResolver', () => { } ] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); @@ -313,7 +303,7 @@ describe('fileResolver', () => { expect(result[1].sizeBytes).toBeGreaterThan(0); }); - it('should only check last 2 user messages', async () => { + it('should detect attachment at any position within the current turn', async () => { vi.mocked(existsSync).mockReturnValue(true); const mockSession: Session = { id: mockSessionId, @@ -324,80 +314,214 @@ describe('fileResolver', () => { } as Session; vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + // Session layout (chronological, as stored in JSONL): + // [0] assistant message ← turn boundary (scan stops here) + // [1] msg-parent ← non-meta empty (current turn) + // [2] meta-with-image ← isMeta, has base64 (current turn) ← MUST detect + // [3] meta-text-only ← isMeta, no attachment (current turn) + // [4] msg-tool-result ← non-meta tool_result (current turn) const messages: ClaudeMessage[] = [ - // Old user message (should be ignored) { - type: 'user', - uuid: 'msg-old', + type: 'assistant', + uuid: 'asst-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'assistant', content: 'Previous assistant reply' } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-with-image', + parentUuid: 'msg-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /path/to/photo.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'old-image-data' - } + source: { type: 'base64', media_type: 'image/png', data: 'base64-photo-data' } } ] } } as ClaudeMessage, - // Recent user message 1 { type: 'user', - uuid: 'msg-1', + uuid: 'meta-text-only', sessionId: mockSessionId, - timestamp: '2024-01-01T00:00:01Z', + timestamp: '2024-01-01T00:00:03Z', + isMeta: true, + message: { role: 'user', content: [{ type: 'text', text: 'some context' }] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-tool-result', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:04Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'tool output' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].data).toBe('base64-photo-data'); + expect(result[0].fileName).toBe('photo.png'); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + + it('should not detect attachments from a previous turn', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Turn 1: user uploaded an image, assistant replied + // Turn 2: user sends a plain message — no new upload + // detectFileUploadsFromSession must return [] (no current-turn attachment) + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'meta-old', + parentUuid: 'msg-old-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /old/image.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'recent-image-1' - } + source: { type: 'base64', media_type: 'image/png', data: 'old-image-data' } } ] } } as ClaudeMessage, - // Recent user message 2 { type: 'user', - uuid: 'msg-2', + uuid: 'msg-old-parent', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + // Assistant reply — turn boundary + { + type: 'assistant', + uuid: 'asst-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:02Z', + message: { role: 'assistant', content: 'I see your image.' } + } as ClaudeMessage, + // Turn 2 — current turn, plain text only + { + type: 'user', + uuid: 'msg-current', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'Just a text message, no new file' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toEqual([]); + }); + + it('should detect image at position 3 when tool-result messages are at positions 1 and 2', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Reproduces the real evidence from EPMCDME-13907: + // uuid=c8d31c75 tool_result ← pos 1 (most recent, no attachment) + // uuid=a31514f8 isMeta, text ← pos 2 (no attachment) + // uuid=00b98ab8 isMeta, image ← pos 3 (was missed by old RECENT_MESSAGES_LIMIT=2) + // uuid=3677b4c3 non-meta, [] ← parent, empty + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-3677b4c3', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-00b98ab8', + parentUuid: 'msg-3677b4c3', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, message: { role: 'user', content: [ + { type: 'text', text: '[Image: source: /uploads/diagram.png]' }, { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'recent-image-2' - } + source: { type: 'base64', media_type: 'image/png', data: 'base64-diagram' } } ] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-a31514f8', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, + message: { role: 'user', content: [{ type: 'text', text: 'context only' }] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-c8d31c75', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'user', + content: [{ type: 'tool_result', content: 'tool result value' }] + } } as ClaudeMessage ]; vi.mocked(readJSONL).mockResolvedValue(messages); const result = await detectFileUploadsFromSession(mockSessionId); - expect(result).toHaveLength(2); - expect(result[0].data).toBe('recent-image-2'); // Most recent first + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('diagram.png'); + expect(result[0].data).toBe('base64-diagram'); + expect(result[0].type).toBe('image'); expect(result[0].sizeBytes).toBeGreaterThan(0); - expect(result[1].data).toBe('recent-image-1'); - expect(result[1].sizeBytes).toBeGreaterThan(0); }); - it('should generate fallback filename when meta message is missing', async () => { + it('should generate fallback filename when filename annotation is absent from meta message', async () => { vi.mocked(existsSync).mockReturnValue(true); const mockSession: Session = { id: mockSessionId, @@ -414,6 +538,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -455,6 +580,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -497,6 +623,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -542,6 +669,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -587,6 +715,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -630,6 +759,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -674,6 +804,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ @@ -718,6 +849,7 @@ describe('fileResolver', () => { uuid: 'msg-1', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + isMeta: true, message: { role: 'user', content: [ diff --git a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts index 54615e102..a447f66af 100644 --- a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts +++ b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts @@ -16,7 +16,6 @@ import type { Session } from '@/agents/core/session/types.js'; import { getSessionPath } from '@/agents/core/session/session-config.js'; const ATTACHMENT_PATH_PATTERN = /\[(Image|Document): source: ([^\]]+)\]/g; -const RECENT_MESSAGES_LIMIT = 2; const MAX_FILE_SIZE_MB = 100; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const BYTES_PER_KB = 1024; @@ -24,6 +23,7 @@ const BYTES_PER_MB = 1024 * 1024; const MESSAGE_TYPE = { USER: 'user', + ASSISTANT: 'assistant', TEXT: 'text', IMAGE: 'image', DOCUMENT: 'document' @@ -70,47 +70,6 @@ function extractFileName(filePath: string): string { return basename(filePath); } -function extractFileNamesFromMetaMessage(message: ClaudeMessage): string[] { - if (!message.isMeta || !message.parentUuid || !Array.isArray(message.message?.content)) { - return []; - } - - const fileNames: string[] = []; - for (const item of message.message.content) { - if (item.type === MESSAGE_TYPE.TEXT && item.text) { - const matches = item.text.matchAll(ATTACHMENT_PATH_PATTERN); - for (const match of matches) { - fileNames.push(extractFileName(match[2])); - } - } - } - - return fileNames; -} - -function buildAttachmentMap(messages: ClaudeMessage[]): Map { - const attachmentMap = new Map(); - const messagesWithAttachments = new Set(); - - for (const msg of messages) { - if (msg.type === MESSAGE_TYPE.USER && msg.uuid && Array.isArray(msg.message?.content)) { - const hasAttachment = msg.message.content.some(item => isAttachmentType(item.type)); - if (hasAttachment) { - messagesWithAttachments.add(msg.uuid); - } - } - } - - for (const msg of messages) { - const fileNames = extractFileNamesFromMetaMessage(msg); - if (fileNames.length > 0 && msg.parentUuid && messagesWithAttachments.has(msg.parentUuid)) { - const existing = attachmentMap.get(msg.parentUuid) || []; - attachmentMap.set(msg.parentUuid, [...existing, ...fileNames]); - } - } - - return attachmentMap; -} function readSessionMetadata(sessionId: string): Session | null { const sessionPath = getSessionPath(sessionId); @@ -150,22 +109,6 @@ function extractAgentSessionFile(session: Session): string | null { return agentSessionFile; } -function getRecentUserMessages(messages: ClaudeMessage[]): ClaudeMessage[] { - const recentMessages: ClaudeMessage[] = []; - - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (msg.type === MESSAGE_TYPE.USER && msg.uuid) { - recentMessages.push(msg); - if (recentMessages.length >= RECENT_MESSAGES_LIMIT) { - break; - } - } - } - - return recentMessages; -} - function processFileItem( item: { type: string; source?: { type?: string; data?: string; media_type?: string } }, fileName: string, @@ -211,42 +154,44 @@ function processFileItem( } } -function extractFileContentFromMessages( - messages: ClaudeMessage[], - attachmentMap: Map -): DetectedFile[] { +function extractFileContentFromMessages(messages: ClaudeMessage[]): DetectedFile[] { const detectedFiles: DetectedFile[] = []; - const recentUserMessages = getRecentUserMessages(messages); - if (recentUserMessages.length === 0) { - logger.debug(`${LOG_PREFIX} No user messages found`); - return []; - } + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + + if (msg.type === MESSAGE_TYPE.ASSISTANT) { + break; + } - for (const userMessage of recentUserMessages) { - if (!Array.isArray(userMessage.message?.content)) { + if (!msg.isMeta || !Array.isArray(msg.message?.content)) { continue; } - const fileNames = attachmentMap.get(userMessage.uuid!) ?? []; - let fileIndex = 0; + const fileNames: string[] = []; + for (const item of msg.message.content) { + if (item.type === MESSAGE_TYPE.TEXT && item.text) { + const matches = item.text.matchAll(ATTACHMENT_PATH_PATTERN); + for (const match of matches) { + fileNames.push(extractFileName(match[2])); + } + } + } - for (const item of userMessage.message.content) { + let fileIndex = 0; + for (const item of msg.message.content) { if (isAttachmentType(item.type)) { const fileName = fileNames[fileIndex] ?? generateFallbackFileName(detectedFiles.length, fileIndex); - const detectedFile = processFileItem(item, fileName, userMessage.uuid); - + const detectedFile = processFileItem(item, fileName, msg.uuid); if (detectedFile) { detectedFiles.push(detectedFile); } - fileIndex++; } } } - logger.debug(`${LOG_PREFIX} Checked recent messages`, { - messagesChecked: recentUserMessages.length, + logger.debug(`${LOG_PREFIX} Checked current-turn messages`, { filesFound: detectedFiles.length }); @@ -412,8 +357,7 @@ export async function detectFileUploadsFromSession( agentSessionFile }); - const attachmentMap = buildAttachmentMap(messages); - const detectedFiles = extractFileContentFromMessages(messages, attachmentMap); + const detectedFiles = extractFileContentFromMessages(messages); logDetectedFiles(detectedFiles, quiet); From d12cddbb7827f03e71c2521eba9395d7a539f66d Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Tue, 4 Aug 2026 18:54:39 +0400 Subject: [PATCH 2/6] fix(assistants): use promptId-based grouping to detect uploads in Claude sessions The previous turn-boundary scan (stopping at type==='assistant') failed in real Claude Code JSONL because: - Bug A: the assistant tool_use entry appears *after* the user's isMeta messages for the same prompt, so the scan broke before reaching the images. - Bug B: base64 data and the [Image: source: /path] filename live in two *separate* isMeta entries (not the same one), so the old single-message pass produced the image with a fallback filename. Fix: use the promptId field that Claude Code stamps on every message in a single prompt turn. Find the most recent non-meta user message, capture its promptId, collect all isMeta messages sharing that id, gather filenames across them first, then match positionally to base64 attachment items. Also adds promptId to the ClaudeMessage interface and updates test fixtures to reflect the real split-message JSONL structure. Generated with AI Co-Authored-By: codemie-ai --- .../plugins/claude/claude-message-types.ts | 1 + .../__tests__/claudeUploadsDetector.test.ts | 233 +++++++++++++++++- .../assistants/chat/claudeUploadsDetector.ts | 47 ++-- 3 files changed, 257 insertions(+), 24 deletions(-) diff --git a/src/agents/plugins/claude/claude-message-types.ts b/src/agents/plugins/claude/claude-message-types.ts index 28a8677c4..15a4943fe 100644 --- a/src/agents/plugins/claude/claude-message-types.ts +++ b/src/agents/plugins/claude/claude-message-types.ts @@ -12,6 +12,7 @@ export interface ClaudeMessage { type: 'user' | 'assistant' | 'system' | string; subtype?: 'api_error' | string; // For system messages uuid: string; + promptId?: string; // Groups all messages belonging to one user prompt turn parentUuid?: string; sessionId: string; timestamp: string; diff --git a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts index 58dff3232..9f93d84b1 100644 --- a/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts +++ b/src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts @@ -396,10 +396,13 @@ describe('fileResolver', () => { // Turn 1: user uploaded an image, assistant replied // Turn 2: user sends a plain message — no new upload // detectFileUploadsFromSession must return [] (no current-turn attachment) + // promptId is the grouping key: turn-1 messages share 'prompt-prev', + // turn-2 message has 'prompt-curr' (no isMeta in turn 2) const messages: ClaudeMessage[] = [ { type: 'user', uuid: 'meta-old', + promptId: 'prompt-prev', parentUuid: 'msg-old-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', @@ -418,6 +421,7 @@ describe('fileResolver', () => { { type: 'user', uuid: 'msg-old-parent', + promptId: 'prompt-prev', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:01Z', message: { role: 'user', content: [] } @@ -434,6 +438,7 @@ describe('fileResolver', () => { { type: 'user', uuid: 'msg-current', + promptId: 'prompt-curr', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:03Z', message: { @@ -449,6 +454,162 @@ describe('fileResolver', () => { expect(result).toEqual([]); }); + it('should detect image when base64 and filename are in separate isMeta messages sharing the same promptId (Bug B)', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Real Claude Code JSONL structure (confirmed by live test): + // isMeta A — contains only base64 image data, no [Image: source:] text + // isMeta B — contains only [Image: source: /path] text, no base64 + // Both share the same promptId; the algorithm must cross-message-match them + const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'; + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-parent', + promptId: 'prompt-abc', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-base64', + promptId: 'prompt-abc', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Data } } + ] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-filename', + promptId: 'prompt-abc', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: '[Image: source: /path/to/screenshot.png]' } + ] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('screenshot.png'); + expect(result[0].data).toBe(base64Data); + expect(result[0].mediaType).toBe('image/png'); + expect(result[0].type).toBe('image'); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + + it('should detect image even when an assistant tool_use message appears after isMeta messages within the same prompt (Bug A)', async () => { + vi.mocked(existsSync).mockReturnValue(true); + const mockSession: Session = { + id: mockSessionId, + correlation: { + status: 'matched', + agentSessionFile: mockAgentSessionFile + } + } as Session; + vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockSession)); + + // Real Claude Code JSONL order (confirmed by live test): + // msg-parent — non-meta user message (the triggering prompt) + // meta-base64 — isMeta, base64 only + // meta-filename — isMeta, [Image: source:] text only + // asst-tool-use — assistant tool_use (Claude starts executing before bash returns) + // tool-result — user tool_result message + // + // The old turn-boundary scan hit asst-tool-use and stopped before reaching meta-base64. + const base64Data = 'aW1hZ2VkYXRh'; + const messages: ClaudeMessage[] = [ + { + type: 'user', + uuid: 'msg-parent', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-base64', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64Data } } + ] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'meta-filename', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:02Z', + isMeta: true, + message: { + role: 'user', + content: [ + { type: 'text', text: '[Image: source: /uploads/photo.png]' } + ] + } + } as ClaudeMessage, + // Assistant tool_use appears AFTER isMeta messages (Bug A trigger) + { + type: 'assistant', + uuid: 'asst-tool-use', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:03Z', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'process' } }] + } + } as ClaudeMessage, + { + type: 'user', + uuid: 'tool-result', + promptId: 'prompt-xyz', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:04Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }] + } + } as ClaudeMessage + ]; + vi.mocked(readJSONL).mockResolvedValue(messages); + + const result = await detectFileUploadsFromSession(mockSessionId); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('photo.png'); + expect(result[0].data).toBe(base64Data); + expect(result[0].sizeBytes).toBeGreaterThan(0); + }); + it('should detect image at position 3 when tool-result messages are at positions 1 and 2', async () => { vi.mocked(existsSync).mockReturnValue(true); const mockSession: Session = { @@ -535,9 +696,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -577,9 +745,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -620,9 +795,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -666,9 +848,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -712,9 +901,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -756,9 +952,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -801,9 +1004,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', @@ -846,9 +1056,16 @@ describe('fileResolver', () => { const messages: ClaudeMessage[] = [ { type: 'user', - uuid: 'msg-1', + uuid: 'msg-parent', sessionId: mockSessionId, timestamp: '2024-01-01T00:00:00Z', + message: { role: 'user', content: [] } + } as ClaudeMessage, + { + type: 'user', + uuid: 'msg-1', + sessionId: mockSessionId, + timestamp: '2024-01-01T00:00:01Z', isMeta: true, message: { role: 'user', diff --git a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts index a447f66af..55ff46a30 100644 --- a/src/cli/commands/assistants/chat/claudeUploadsDetector.ts +++ b/src/cli/commands/assistants/chat/claudeUploadsDetector.ts @@ -11,7 +11,7 @@ import chalk from 'chalk'; import mime from 'mime-types'; import { logger } from '@/utils/logger.js'; import { readJSONL } from '@/agents/core/session/utils/jsonl-reader.js'; -import type { ClaudeMessage } from '@/agents/plugins/claude/claude-message-types.js'; +import type { ClaudeMessage, ContentItem } from '@/agents/plugins/claude/claude-message-types.js'; import type { Session } from '@/agents/core/session/types.js'; import { getSessionPath } from '@/agents/core/session/session-config.js'; @@ -155,21 +155,34 @@ function processFileItem( } function extractFileContentFromMessages(messages: ClaudeMessage[]): DetectedFile[] { - const detectedFiles: DetectedFile[] = []; - + // Step 1: find the most recent non-meta user message and capture its promptId. + // promptId groups all JSONL entries belonging to one user prompt turn. + let foundAnchor = false; + let anchorPromptId: string | undefined; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - - if (msg.type === MESSAGE_TYPE.ASSISTANT) { + if (msg.type === MESSAGE_TYPE.USER && !msg.isMeta) { + foundAnchor = true; + anchorPromptId = msg.promptId; break; } + } + if (!foundAnchor) { + logger.debug(`${LOG_PREFIX} Checked current-turn messages`, { filesFound: 0 }); + return []; + } - if (!msg.isMeta || !Array.isArray(msg.message?.content)) { - continue; - } - - const fileNames: string[] = []; - for (const item of msg.message.content) { + // Step 2: collect all isMeta messages that share the anchor's promptId. + // In real Claude Code JSONL the base64 data and the [Image: source:] filename + // text live in separate isMeta entries — both carry the same promptId. + const promptMessages = messages.filter( + msg => msg.isMeta && msg.promptId === anchorPromptId && Array.isArray(msg.message?.content) + ); + + // Step 3: gather every filename annotation across all those messages first. + const fileNames: string[] = []; + for (const msg of promptMessages) { + for (const item of msg.message!.content as ContentItem[]) { if (item.type === MESSAGE_TYPE.TEXT && item.text) { const matches = item.text.matchAll(ATTACHMENT_PATH_PATTERN); for (const match of matches) { @@ -177,15 +190,17 @@ function extractFileContentFromMessages(messages: ClaudeMessage[]): DetectedFile } } } + } - let fileIndex = 0; - for (const item of msg.message.content) { + // Step 4: collect every base64 attachment item and match by positional index. + const detectedFiles: DetectedFile[] = []; + let fileIndex = 0; + for (const msg of promptMessages) { + for (const item of msg.message!.content as ContentItem[]) { if (isAttachmentType(item.type)) { const fileName = fileNames[fileIndex] ?? generateFallbackFileName(detectedFiles.length, fileIndex); const detectedFile = processFileItem(item, fileName, msg.uuid); - if (detectedFile) { - detectedFiles.push(detectedFile); - } + if (detectedFile) detectedFiles.push(detectedFile); fileIndex++; } } From bfb011c8493478f532cb709aee59d2c376805187 Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Tue, 4 Aug 2026 19:27:27 +0400 Subject: [PATCH 3/6] fix(assistants): always use CODEMIE_SESSION_ID for file detection, not --conversation-id --conversation-id identifies the assistant chat thread (e.g. a workflow_id generated by a skill). CODEMIE_SESSION_ID identifies the Claude session whose JSONL contains uploaded file blobs. Using --conversation-id for session lookup caused detectFileUploadsFromSession to look for a non-existent session file, returning no attachments even when files were uploaded in the current Claude turn. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_0162w98kuqJ7EWaA6h2FjDCK --- src/cli/commands/assistants/chat/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/assistants/chat/index.ts b/src/cli/commands/assistants/chat/index.ts index bb51d4922..070ad3ea3 100644 --- a/src/cli/commands/assistants/chat/index.ts +++ b/src/cli/commands/assistants/chat/index.ts @@ -95,9 +95,12 @@ async function chatWithAssistant( // Collect files from session and CLI paths let detectedFiles: DetectedFile[] = []; - // 1. Detect files from session (if conversationId exists) - if (conversationId) { - detectedFiles = await detectFileUploadsFromSession(conversationId, { quiet: false }); + // 1. Detect files from the Claude session (always use CODEMIE_SESSION_ID, not --conversation-id). + // --conversation-id identifies the assistant chat thread; CODEMIE_SESSION_ID identifies the + // Claude session whose JSONL contains the uploaded file blobs. + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); } // 2. Read files from --file paths (if provided) From 4b94f8ba204ae3b53407d7760f48fb017b95784b Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Wed, 5 Aug 2026 16:06:18 +0400 Subject: [PATCH 4/6] feat(assistants): implement Codex rollout-based file attachment detection Adds detectCodexFileUploads() which discovers the active Codex rollout by scanning ~/.codex/sessions via getCodexDiscoverySessionRoots() and matching session_meta.cwd to the caller's CWD. Extracts input_image blocks from the most recent user response_item record. Wires CODEMIE_AGENT-aware dispatch in chat/index.ts: when CODEMIE_AGENT equals 'codex', the new detector runs; otherwise the existing Claude CODEMIE_SESSION_ID path is used (no regression). Also adds CodexResponseItemMessage, CodexContentBlock, and extends CodexEventMsg with images/local_images to make attachment types explicit. Generated with AI Co-Authored-By: codemie-ai --- .../plugins/codex/codex-message-types.ts | 27 ++ .../assistants/chat/codexUploadsDetector.ts | 275 ++++++++++++++++++ src/cli/commands/assistants/chat/index.ts | 19 +- 3 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/cli/commands/assistants/chat/codexUploadsDetector.ts diff --git a/src/agents/plugins/codex/codex-message-types.ts b/src/agents/plugins/codex/codex-message-types.ts index cd7df44e4..f8d5bc1a2 100644 --- a/src/agents/plugins/codex/codex-message-types.ts +++ b/src/agents/plugins/codex/codex-message-types.ts @@ -59,6 +59,25 @@ export interface CodexResponseItem { output?: string; // function_call_output: tool output } +/** + * The `message` sub-type of a response_item record — carries user/assistant + * content including text, images, and documents. Not modelled in CodexResponseItem + * because that interface focuses on function-call shapes. + */ +export interface CodexResponseItemMessage { + type: 'message'; + role: 'user' | 'assistant'; + content: CodexContentBlock[]; +} + +/** A single content block within CodexResponseItemMessage.content. */ +export interface CodexContentBlock { + type: 'input_text' | 'input_image' | 'input_file' | string; + text?: string; + /** Data URI: `"data:;base64,"` — present on input_image blocks. */ + image_url?: string; +} + /** event_msg record — user messages, token metering, task lifecycle, collaboration */ export interface CodexEventMsg { type: @@ -82,6 +101,14 @@ export interface CodexEventMsg { model_context_window?: number; } | null; agent_statuses?: Array<{ thread_id?: string; agent_role?: string }>; + /** Populated on user_message — base64 data URIs; typically empty (data lives in response_item). */ + images?: string[]; + /** Populated on user_message — temp file paths on disk for attached images. */ + local_images?: string[]; + text_elements?: Array<{ + byte_range?: { start: number; end: number }; + placeholder?: string; + }>; } export interface CodexTokenUsageBlock { diff --git a/src/cli/commands/assistants/chat/codexUploadsDetector.ts b/src/cli/commands/assistants/chat/codexUploadsDetector.ts new file mode 100644 index 000000000..3093ee622 --- /dev/null +++ b/src/cli/commands/assistants/chat/codexUploadsDetector.ts @@ -0,0 +1,275 @@ +/** + * Codex-specific file attachment detector. + * + * Discovers the active Codex rollout by scanning Codex sessions directories + * (via getCodexDiscoverySessionRoots) for a rollout whose session_meta.cwd + * resolves to process.cwd(), then extracts input_image/input_file blocks from + * the most recent user response_item record. + * + * Uses direct JSONL scanning rather than CodexSessionAdapter because the adapter + * requires AgentMetadata.dataPaths.home which is not available in the CLI layer. + * Codex hooks also do not fire reliably, so hook-based correlation is skipped. + */ + +import { realpath as fsRealpath, readdir, stat } from 'fs/promises'; +import { basename, join } from 'path'; +import chalk from 'chalk'; +import { logger } from '@/utils/logger.js'; +import { readJSONLTolerant } from '@/agents/core/session/utils/jsonl-reader.js'; +import { getCodexDiscoverySessionRoots } from '@/agents/plugins/codex/codex.paths.js'; +import { isCodexInjectedUserText } from '@/agents/plugins/codex/session/codex-user-prompt.js'; +import type { + CodexRolloutRecord, + CodexSessionMeta, + CodexEventMsg, + CodexResponseItemMessage, + CodexContentBlock, +} from '@/agents/plugins/codex/codex-message-types.js'; +import type { DetectedFile } from './claudeUploadsDetector.js'; + +const LOG_PREFIX = '[codexUploadsDetector]'; +const ROLLOUT_FILENAME = /^rollout-.*\.jsonl$/; +const IMAGE_WRAPPER_PATTERN = //; +const MAX_FILE_SIZE_MB = 100; +const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +const BYTES_PER_MB = 1024 * 1024; +const BYTES_PER_KB = 1024; +const DEFAULT_MEDIA_TYPE = 'application/octet-stream'; +const MS_PER_DAY = 86_400_000; + +export interface DetectCodexFileUploadsOptions { + cwd: string; + quiet?: boolean; +} + +async function safeRealpath(p: string): Promise { + try { + return await fsRealpath(p); + } catch { + return p; + } +} + +async function scanRecentRollouts( + sessionsPath: string, + nowMs: number +): Promise> { + const candidates: Array<{ filePath: string; mtime: number }> = []; + + for (let daysBack = 0; daysBack <= 1; daysBack++) { + const d = new Date(nowMs - daysBack * MS_PER_DAY); + const year = d.getFullYear().toString(); + const month = (d.getMonth() + 1).toString().padStart(2, '0'); + const day = d.getDate().toString().padStart(2, '0'); + const dayPath = join(sessionsPath, year, month, day); + + let files: string[]; + try { + files = await readdir(dayPath); + } catch { + continue; + } + + for (const file of files) { + if (!ROLLOUT_FILENAME.test(file)) continue; + const filePath = join(dayPath, file); + try { + const s = await stat(filePath); + candidates.push({ filePath, mtime: s.mtime.getTime() }); + } catch { + // skip unreadable + } + } + } + + return candidates; +} + +async function findMatchingRollout(cwdReal: string, nowMs: number): Promise { + const roots = getCodexDiscoverySessionRoots(); + if (!roots.length) { + logger.debug(`${LOG_PREFIX} No Codex session directories found`); + return null; + } + + const allCandidates: Array<{ filePath: string; mtime: number }> = []; + for (const root of roots) { + const candidates = await scanRecentRollouts(root.sessionsPath, nowMs); + allCandidates.push(...candidates); + } + + allCandidates.sort((a, b) => b.mtime - a.mtime); + + for (const { filePath } of allCandidates) { + const records = await readJSONLTolerant(filePath, LOG_PREFIX); + const metaRecord = records.find((r) => r.type === 'session_meta'); + if (!metaRecord) continue; + + const sessionMeta = metaRecord.payload as CodexSessionMeta; + if (!sessionMeta.cwd) continue; + + const metaReal = await safeRealpath(sessionMeta.cwd); + if (metaReal === cwdReal) { + logger.debug(`${LOG_PREFIX} Matched rollout`, { filePath, cwd: sessionMeta.cwd }); + return filePath; + } + } + + return null; +} + +function processImageBlock(block: CodexContentBlock, fileName: string): DetectedFile | null { + if (!block.image_url) { + logger.warn(`${LOG_PREFIX} input_image block missing image_url`, { fileName }); + return null; + } + + const commaIdx = block.image_url.indexOf(','); + if (commaIdx === -1) { + logger.warn(`${LOG_PREFIX} Malformed data URI in input_image block`, { fileName }); + return null; + } + + const prefix = block.image_url.slice(0, commaIdx); + const base64Data = block.image_url.slice(commaIdx + 1); + const mimeMatch = /data:([^;]+);base64/.exec(prefix); + const mediaType = mimeMatch?.[1] ?? DEFAULT_MEDIA_TYPE; + + try { + const fileSize = Buffer.from(base64Data, 'base64').length; + if (fileSize > MAX_FILE_SIZE_BYTES) { + logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { + fileName, + sizeMB: (fileSize / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + return null; + } + + return { + fileName, + data: base64Data, + mediaType, + type: 'image', + sizeBytes: fileSize, + }; + } catch (error) { + logger.warn(`${LOG_PREFIX} Invalid base64 data`, { fileName, error }); + return null; + } +} + +function extractAttachments(records: CodexRolloutRecord[]): DetectedFile[] { + let targetResponseItem: CodexResponseItemMessage | null = null; + let targetEventMsg: (CodexEventMsg & { local_images?: string[] }) | null = null; + + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]; + + if (!targetResponseItem && record.type === 'response_item') { + const payload = record.payload as unknown as CodexResponseItemMessage; + if ( + payload.type === 'message' && + payload.role === 'user' && + Array.isArray(payload.content) && + payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') + ) { + targetResponseItem = payload; + } + } + + if (!targetEventMsg && record.type === 'event_msg') { + const payload = record.payload as CodexEventMsg & { local_images?: string[] }; + if ( + payload.type === 'user_message' && + typeof payload.message === 'string' && + !isCodexInjectedUserText(payload.message) + ) { + targetEventMsg = payload; + } + } + + if (targetResponseItem && targetEventMsg) break; + } + + if (!targetResponseItem) { + logger.debug(`${LOG_PREFIX} No user response_item with attachments found`); + return []; + } + + const detectedFiles: DetectedFile[] = []; + const content = targetResponseItem.content; + let localImageIndex = 0; + + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (block.type !== 'input_image' && block.type !== 'input_file') continue; + + let fileName: string | undefined; + if (i > 0) { + const prev = content[i - 1]; + if (prev.type === 'input_text' && typeof prev.text === 'string') { + const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); + if (match) fileName = basename(match[1]); + } + } + if (!fileName) { + const localPath = targetEventMsg?.local_images?.[localImageIndex]; + if (localPath) fileName = basename(localPath); + } + fileName = fileName ?? `attachment_${localImageIndex}`; + localImageIndex++; + + if (block.type === 'input_image') { + const detectedFile = processImageBlock(block, fileName); + if (detectedFile) detectedFiles.push(detectedFile); + } + } + + logger.debug(`${LOG_PREFIX} Extracted attachments`, { count: detectedFiles.length }); + return detectedFiles; +} + +/** + * Detect file uploads from the active Codex rollout. + * + * Finds the most recent rollout in the Codex sessions directories whose + * session_meta.cwd resolves to `options.cwd`, then extracts any files + * attached in the most recent user turn. + */ +export async function detectCodexFileUploads( + options: DetectCodexFileUploadsOptions +): Promise { + const { cwd, quiet = false } = options; + + logger.debug(`${LOG_PREFIX} Detecting file uploads from Codex rollout`, { cwd }); + + try { + const nowMs = Date.now(); + const cwdReal = await safeRealpath(cwd); + const rolloutPath = await findMatchingRollout(cwdReal, nowMs); + + if (!rolloutPath) { + logger.debug(`${LOG_PREFIX} No matching rollout found for cwd`, { cwdReal }); + return []; + } + + const records = await readJSONLTolerant(rolloutPath, LOG_PREFIX); + const detectedFiles = extractAttachments(records); + + if (!quiet && detectedFiles.length > 0) { + console.log(chalk.cyan(`\n📎 Detected ${detectedFiles.length} file(s) with content:`)); + detectedFiles.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); + } + + logger.debug(`${LOG_PREFIX} Detection complete`, { filesDetected: detectedFiles.length }); + return detectedFiles; + } catch (error) { + logger.debug(`${LOG_PREFIX} Failed to detect file uploads`, { error }); + return []; + } +} diff --git a/src/cli/commands/assistants/chat/index.ts b/src/cli/commands/assistants/chat/index.ts index 070ad3ea3..f28748f12 100644 --- a/src/cli/commands/assistants/chat/index.ts +++ b/src/cli/commands/assistants/chat/index.ts @@ -22,6 +22,7 @@ import { appendConversationTurn } from './historyPersister.js'; import { isExitCommand, enableVerboseMode } from './utils.js'; import type { ChatCommandOptions, SingleMessageOptions } from './types.js'; import { detectFileUploadsFromSession, readFilesFromPaths, type DetectedFile } from './claudeUploadsDetector.js'; +import { detectCodexFileUploads } from './codexUploadsDetector.js'; /** Assistant label color */ const ASSISTANT_LABEL_COLOR = [177, 185, 249] as const; @@ -95,12 +96,18 @@ async function chatWithAssistant( // Collect files from session and CLI paths let detectedFiles: DetectedFile[] = []; - // 1. Detect files from the Claude session (always use CODEMIE_SESSION_ID, not --conversation-id). - // --conversation-id identifies the assistant chat thread; CODEMIE_SESSION_ID identifies the - // Claude session whose JSONL contains the uploaded file blobs. - const claudeSessionId = process.env.CODEMIE_SESSION_ID; - if (claudeSessionId) { - detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + // 1. Detect files from the agent session. + // CODEMIE_AGENT selects the detection strategy: + // - 'codex': scan Codex rollout JSONL by CWD match (Codex hooks are non-functional) + // - default: read Claude session JSONL via CODEMIE_SESSION_ID → correlation → agentSessionFile + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else { + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } } // 2. Read files from --file paths (if provided) From 3c2029c5f53cb7eb1b07ef7aad65e99ed7dceb42 Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Wed, 5 Aug 2026 16:35:23 +0400 Subject: [PATCH 5/6] fix(assistants): correct three bugs in Codex file attachment detection - CR-001: use imageOnlyIndex (increments only on input_image) for local_images lookups; the prior localImageIndex incremented on input_file blocks too, misaligning filename resolution in mixed file+image turns - CR-002: find targetEventMsg in a second backward pass bounded to records at or before the response_item position, so a follow-up message no longer severs the filename chain from the attachment turn - CR-003: estimate decoded size with base64 length math before the size guard, avoiding a full Buffer.from allocation that could OOM and silently drop valid attachments; also reject empty data URI payloads early Generated with AI Co-Authored-By: codemie-ai --- .../assistants/chat/codexUploadsDetector.ts | 127 ++++++++++-------- 1 file changed, 70 insertions(+), 57 deletions(-) diff --git a/src/cli/commands/assistants/chat/codexUploadsDetector.ts b/src/cli/commands/assistants/chat/codexUploadsDetector.ts index 3093ee622..facbf75f6 100644 --- a/src/cli/commands/assistants/chat/codexUploadsDetector.ts +++ b/src/cli/commands/assistants/chat/codexUploadsDetector.ts @@ -132,64 +132,58 @@ function processImageBlock(block: CodexContentBlock, fileName: string): Detected const prefix = block.image_url.slice(0, commaIdx); const base64Data = block.image_url.slice(commaIdx + 1); + + if (!base64Data) { + logger.warn(`${LOG_PREFIX} Empty data URI payload in input_image block`, { fileName }); + return null; + } + const mimeMatch = /data:([^;]+);base64/.exec(prefix); const mediaType = mimeMatch?.[1] ?? DEFAULT_MEDIA_TYPE; - try { - const fileSize = Buffer.from(base64Data, 'base64').length; - if (fileSize > MAX_FILE_SIZE_BYTES) { - logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { - fileName, - sizeMB: (fileSize / BYTES_PER_MB).toFixed(2), - limit: MAX_FILE_SIZE_MB, - }); - return null; - } + // Estimate decoded size without allocating the full buffer (Buffer.from on a large + // payload can OOM before the size guard runs, silently dropping a valid attachment). + const paddingChars = base64Data.endsWith('==') ? 2 : base64Data.endsWith('=') ? 1 : 0; + const sizeBytes = Math.ceil(base64Data.length * 3 / 4) - paddingChars; - return { + if (sizeBytes > MAX_FILE_SIZE_BYTES) { + logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { fileName, - data: base64Data, - mediaType, - type: 'image', - sizeBytes: fileSize, - }; - } catch (error) { - logger.warn(`${LOG_PREFIX} Invalid base64 data`, { fileName, error }); + sizeMB: (sizeBytes / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); return null; } + + return { + fileName, + data: base64Data, + mediaType, + type: 'image', + sizeBytes, + }; } function extractAttachments(records: CodexRolloutRecord[]): DetectedFile[] { let targetResponseItem: CodexResponseItemMessage | null = null; let targetEventMsg: (CodexEventMsg & { local_images?: string[] }) | null = null; + // First pass: locate the most-recent user response_item that carries attachments. + let responseItemIndex = -1; for (let i = records.length - 1; i >= 0; i--) { const record = records[i]; - - if (!targetResponseItem && record.type === 'response_item') { - const payload = record.payload as unknown as CodexResponseItemMessage; - if ( - payload.type === 'message' && - payload.role === 'user' && - Array.isArray(payload.content) && - payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') - ) { - targetResponseItem = payload; - } - } - - if (!targetEventMsg && record.type === 'event_msg') { - const payload = record.payload as CodexEventMsg & { local_images?: string[] }; - if ( - payload.type === 'user_message' && - typeof payload.message === 'string' && - !isCodexInjectedUserText(payload.message) - ) { - targetEventMsg = payload; - } + if (record.type !== 'response_item') continue; + const payload = record.payload as unknown as CodexResponseItemMessage; + if ( + payload.type === 'message' && + payload.role === 'user' && + Array.isArray(payload.content) && + payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') + ) { + targetResponseItem = payload; + responseItemIndex = i; + break; } - - if (targetResponseItem && targetEventMsg) break; } if (!targetResponseItem) { @@ -197,33 +191,52 @@ function extractAttachments(records: CodexRolloutRecord[]): DetectedFile[] { return []; } + // Second pass: find the matching event_msg at or before the response_item's position, + // so a later follow-up message never severs the filename chain. + for (let i = responseItemIndex; i >= 0; i--) { + const record = records[i]; + if (record.type !== 'event_msg') continue; + const payload = record.payload as CodexEventMsg & { local_images?: string[] }; + if ( + payload.type === 'user_message' && + typeof payload.message === 'string' && + !isCodexInjectedUserText(payload.message) + ) { + targetEventMsg = payload; + break; + } + } + const detectedFiles: DetectedFile[] = []; const content = targetResponseItem.content; - let localImageIndex = 0; + // imageOnlyIndex tracks position within local_images (images-only array) separately + // from input_file blocks, which do not appear in local_images. + let imageOnlyIndex = 0; for (let i = 0; i < content.length; i++) { const block = content[i]; if (block.type !== 'input_image' && block.type !== 'input_file') continue; - let fileName: string | undefined; - if (i > 0) { - const prev = content[i - 1]; - if (prev.type === 'input_text' && typeof prev.text === 'string') { - const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); - if (match) fileName = basename(match[1]); + if (block.type === 'input_image') { + let fileName: string | undefined; + if (i > 0) { + const prev = content[i - 1]; + if (prev.type === 'input_text' && typeof prev.text === 'string') { + const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); + if (match) fileName = basename(match[1]); + } } - } - if (!fileName) { - const localPath = targetEventMsg?.local_images?.[localImageIndex]; - if (localPath) fileName = basename(localPath); - } - fileName = fileName ?? `attachment_${localImageIndex}`; - localImageIndex++; + if (!fileName) { + const localPath = targetEventMsg?.local_images?.[imageOnlyIndex]; + if (localPath) fileName = basename(localPath); + } + fileName = fileName ?? `attachment_${imageOnlyIndex}`; + imageOnlyIndex++; - if (block.type === 'input_image') { const detectedFile = processImageBlock(block, fileName); if (detectedFile) detectedFiles.push(detectedFile); } + // input_file blocks are not yet uploadable (no non-image upload path); skip silently. } logger.debug(`${LOG_PREFIX} Extracted attachments`, { count: detectedFiles.length }); From 9b5e1b5ee85bbed1dbdd4886d3f6969b3240f056 Mon Sep 17 00:00:00 2001 From: SergeyVNikitin Date: Wed, 5 Aug 2026 16:41:17 +0400 Subject: [PATCH 6/6] chore: add sdlc-light task artifacts for EPMCDME-13885 Planning, review, and complexity-assessment artifacts for the Codex file attachment detection implementation (sdlc-light flow). Generated with AI Co-Authored-By: codemie-ai --- .../actual-complexity.json | 71 ++ .../code-review-check.json | 51 + .../code-review-final.json | 77 ++ .../events.jsonl | 2 + .../plan.md | 942 ++++++++++++++++++ .../technical-analysis.md | 270 +++++ 6 files changed, 1413 insertions(+) create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md create mode 100644 docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json new file mode 100644 index 000000000..3137d849e --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/actual-complexity.json @@ -0,0 +1,71 @@ +{ + "task": "Implement Codex file attachment detection for CodeMie assistants by scanning rollout JSONL files matched by CWD realpath and dispatching to the existing upload pipeline.", + "generated": "2026-08-05T00:00:00Z", + "dimensions": { + "component_scope": { + "score": 3, + "label": "M", + "affected": "codexUploadsDetector (new CLI-service module), codex-message-types (Agent-Tool type definitions), chat index.ts (CLI dispatch logic)", + "layers": "CLI-Command, CLI-Service, Agent-Tool" + }, + "requirements_clarity": { + "score": 2, + "label": "S", + "status": "Clear", + "gaps": null + }, + "technical_risk": { + "score": 3, + "label": "M", + "risk_factors": "Two-pass JSONL extraction logic, image_wrapper_pattern inline text parsing for filename resolution, local_images[] fallback, OOM-safe base64 size estimation without full buffer allocation — no exact copy-paste precedent in codebase", + "mitigation": "Additive-only change; errors silently return empty array; existing upload pipeline reused unchanged; easily rolled back" + }, + "file_change_estimate": { + "score": 2, + "label": "S", + "modified_files": 2, + "modified_file_list": [ + "src/agents/plugins/codex/codex-message-types.ts", + "src/cli/commands/assistants/chat/index.ts" + ], + "new_files": 1, + "new_file_list": [ + "src/cli/commands/assistants/chat/codexUploadsDetector.ts" + ], + "affected_dirs": [ + "src/cli/commands/assistants/chat", + "src/agents/plugins/codex" + ] + }, + "dependencies": { + "score": 1, + "label": "XS", + "new_packages": [], + "version_changes": [] + }, + "affected_layers": { + "score": 2, + "label": "S", + "layers_changed": ["CLI-Command", "Agent-Tool"], + "schema_migration": false, + "cross_system": false + } + }, + "total": 13, + "size": "S", + "band_range": "10-14", + "files_changed": 3, + "routing": "writing-plans", + "key_reasoning": [ + { + "dimension": "component_scope", + "reason": "Three components touched across two distinct layers: new codexUploadsDetector service module, extended codex-message-types type file, and modified chat index.ts dispatch. Additive and self-contained within the assistants chat subsystem." + }, + { + "dimension": "technical_risk", + "reason": "No exact pattern to copy: two-pass JSONL extraction, realpath CWD matching across multiple session roots, filename resolution via image_wrapper_pattern text parsing or local_images fallback, and an OOM-safe size guard using arithmetic rather than Buffer allocation. Logic is novel but low-stakes — all errors return empty array and the upload pipeline is unchanged." + } + ], + "red_flags_applied": [], + "split_recommendation": null +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json new file mode 100644 index 000000000..df579b47a --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-check.json @@ -0,0 +1,51 @@ +{ + "schema": 1, + "gate_id": "code-review.check", + "decision": "approve", + "confidence": "low", + "rationale": "All three major findings from the final round are resolved in the fix-up commit. CR-001: imageOnlyIndex counter now increments only for input_image blocks. CR-002: two-pass scan anchors event_msg search to records at or before the response_item position. CR-003: Buffer.from allocation removed; size estimated via base64 length math, empty data URI guard added. Confidence remains low because the round carries forward no-spec from the final round.", + "risk_flags": [], + "business_review": [], + "standards_review": [ + { + "standard": "Conventional Commits format", + "source": ".ai-run/guides/standards/git-workflow.md", + "status": "pass", + "notes": "Fix-up commit: 'fix(assistants): correct three bugs in Codex file attachment detection' — valid conventional commit format." + }, + { + "standard": "TypeScript code quality", + "source": ".ai-run/guides/standards/code-quality.md", + "status": "pass", + "notes": "Fix-up changes: no new any casts, no missing return types, all imports unchanged." + }, + { + "standard": "Security practices", + "status": "pass", + "notes": "Carried forward from final round — no changes to security surface in fix-up." + } + ], + "findings": [], + "finding_status": [ + { + "id": "CR-001", + "status": "resolved", + "notes": "imageOnlyIndex replaces localImageIndex; increment is now inside the if (block.type === 'input_image') branch." + }, + { + "id": "CR-002", + "status": "resolved", + "notes": "Two-pass scan: first pass finds response_item and records its index; second pass searches for event_msg only at indices <= responseItemIndex." + }, + { + "id": "CR-003", + "status": "resolved", + "notes": "Buffer.from removed. Size computed via Math.ceil(base64Data.length * 3 / 4) - paddingChars. Empty data URI guard (if (!base64Data)) added before size check." + }, + { + "id": "CR-004", + "status": "no_change_needed", + "notes": "Deferred — minor finding, not a blocking issue. Empty data URI guard added as part of CR-003 fix also partially addresses this." + } + ] +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json new file mode 100644 index 000000000..b3af3949b --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/code-review-final.json @@ -0,0 +1,77 @@ +{ + "schema": 1, + "gate_id": "code-review.final", + "decision": "request-changes", + "confidence": "low", + "rationale": "Three major correctness bugs found in codexUploadsDetector.ts: (1) localImageIndex counter is shared between input_file and input_image blocks causing filename misalignment in mixed-attachment turns; (2) the backward scan independently locates targetResponseItem and targetEventMsg, so a follow-up user message severs the filename chain from the attachment turn; (3) Buffer.from() allocates the full decoded image before the 100 MB guard, which can OOM-fail and misclassify a valid attachment as invalid base64. Confidence is low because sdlc-light has no spec artifact — no acceptance criteria were available to audit.", + "risk_flags": [], + "business_review": [], + "standards_review": [ + { + "standard": "Conventional Commits format", + "source": ".ai-run/guides/standards/git-workflow.md", + "status": "pass", + "notes": "All commits in range follow (): format. Feature commit: 'feat(assistants): implement Codex rollout-based file attachment detection'." + }, + { + "standard": "TypeScript code quality", + "source": ".ai-run/guides/standards/code-quality.md", + "status": "pass", + "notes": "Exported function has explicit return type, interfaces use PascalCase, all imports use .js extension, no bare require(). File naming deviates from kebab-case guide (codexUploadsDetector.ts vs kebab-case) but matches the pre-existing claudeUploadsDetector.ts pattern in the same directory." + }, + { + "standard": "Security practices", + "source": ".ai-run/guides/security/security-practices.md", + "status": "pass", + "notes": "No credentials or tokens logged. Base64 image data not echoed to console or logs (only metadata: fileName, mediaType, sizeKB). Data URIs sourced from trusted local Codex rollout files, not external input. Size limit enforced. No path traversal exposure (CWD from process.cwd())." + } + ], + "findings": [ + { + "id": "CR-001", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "extractAttachments() — the localImageIndex counter, lines 202–226", + "problem": "localImageIndex is incremented for every attachment block (including input_file), but local_images in CodexEventMsg contains only image paths; when input_file blocks precede input_image blocks in the same user turn, the image-filename lookups into local_images are offset by the number of file blocks.", + "impact": "In a mixed file+image upload (e.g., user attaches a PDF and a screenshot together), every input_image block following any input_file block receives the wrong local_images entry as its filename fallback, falling through to the generic 'attachment_N' name and discarding the real filename.", + "recommendation": "Use a separate imageOnlyIndex that increments only when block.type === 'input_image', and use that counter for local_images lookups.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-002", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "extractAttachments() — dual backward scan, lines 166–193", + "problem": "targetResponseItem and targetEventMsg are located by two independent predicates in a single backward scan that stops when both are found; when the user sends a follow-up text message after uploading a file in the same rollout, the scan finds the follow-up's event_msg (no local_images) while still finding the attachment's response_item, severing the local_images filename chain.", + "impact": "Any session where the user types a follow-up message after uploading an attachment will produce attachments with only the generic 'attachment_N' filename instead of the real filename for images whose preceding input_text block does not contain the wrapper pattern.", + "recommendation": "After locating targetResponseItem, search backward for an event_msg only among records at a lower index than the response_item, so both records are anchored to the same conversation turn.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-003", + "severity": "major", + "triage": "patch", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "processImageBlock() — Buffer.from call, line 139", + "problem": "Buffer.from(base64Data, 'base64') allocates the full decoded buffer (up to ~75 MB) solely to obtain .length before the size guard is applied; on memory-constrained environments this throws, and the catch block misclassifies a valid attachment as 'Invalid base64 data'.", + "impact": "A legitimate image just under the 100 MB limit can be silently dropped and logged as malformed base64 when the process is under memory pressure, with no indication that detection failed due to allocation rather than data corruption.", + "recommendation": "Pre-check size with Math.ceil(base64Data.length * 3 / 4) (minus trailing '=' chars) and bail before Buffer.from if the estimate exceeds MAX_FILE_SIZE_BYTES; only allocate for images that pass the guard.", + "sources": ["blind", "edge-case"] + }, + { + "id": "CR-004", + "severity": "minor", + "triage": "defer", + "file": "src/cli/commands/assistants/chat/codexUploadsDetector.ts", + "location": "processImageBlock(), lines 127–155", + "problem": "An image_url of the form 'data:image/png;base64,' (comma present, empty payload) passes all guards and returns a structurally valid DetectedFile with data: '' and sizeBytes: 0.", + "impact": "A downstream SDK call receives a well-formed DetectedFile whose data field is an empty string; any code that forwards this as an image will silently produce a zero-byte attachment without an error at detection time.", + "recommendation": "Add a guard after extracting base64Data — if (!base64Data) { logger.warn(…); return null; } — to reject empty data URIs.", + "sources": ["edge-case"] + } + ], + "dismissed_count": 1, + "dismissed_note": "E-003 (Claude path no longer uses --conversation-id for file detection) dismissed — this behavior change is intentional per cherry-picked commit bfb011c8 'fix(assistants): always use CODEMIE_SESSION_ID for file detection, not --conversation-id' from EPMCDME-13907, which is the documented correct behavior." +} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl new file mode 100644 index 000000000..d2f4d32b7 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/events.jsonl @@ -0,0 +1,2 @@ +{"event":"lifecycle_emission","intent":"artifact_published","artifact_kind":"plan","status":"succeeded"} +{"event":"lifecycle_emission","intent":"record_complexity_score","assessment_mode":"actual","status":"succeeded"} diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md new file mode 100644 index 000000000..10c663a14 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/plan.md @@ -0,0 +1,942 @@ +# Codex File Attachments Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend CodeMie assistant file attachment detection to support Codex rollout JSONL, achieving feature parity with the existing Claude session-based detection. + +**Architecture:** A new `codexUploadsDetector.ts` discovers the active Codex rollout by scanning `~/.codex/sessions/**/*.jsonl` via `getCodexDiscoverySessionRoots()` + CWD realpath matching, then extracts `input_image`/`input_file` blocks from the user `response_item` record. `chat/index.ts` gains an `CODEMIE_AGENT`-aware dispatch branch. A shared `uploads-types.ts` holds the `DetectedFile` interface to avoid import coupling. + +**Tech Stack:** TypeScript (ES modules), Vitest, Node.js `fs/promises`, `readJSONLTolerant`, existing `getCodexDiscoverySessionRoots()`, `isCodexInjectedUserText()`, `chalk`, `mime-types`. + +## Global Constraints + +- All imports use `.js` extension (ES modules). +- Use `@/` alias for imports crossing the `src/` boundary; relative imports within the same directory are fine. +- No `require()`, no `__dirname`; use `import.meta.url` if a dirname is needed. +- Error handling: `try/catch` with `logger.error` or `logger.debug`; detection functions return `[]` on failure, never throw. +- No `console.log` in library code except for the user-facing chalk output already established in `claudeUploadsDetector.ts` (`📎 Detected N file(s)…`). +- Tests are Vitest; mock with `vi.mock()` + dynamic imports after setup. +- Commit messages: Conventional Commits (`feat(assistants): …`). + +--- + +## File Map + +| Path | Action | Purpose | +|---|---|---| +| `src/cli/commands/assistants/chat/uploads-types.ts` | **Create** | Shared `DetectedFile` interface + `readFilesFromPaths` + shared constants | +| `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` | **Modify** | Re-export `DetectedFile`/`readFilesFromPaths` from `uploads-types.ts`; remove local definitions | +| `src/agents/plugins/codex/codex-message-types.ts` | **Modify** | Add `CodexResponseItemMessage`, `CodexContentBlock`, extend `CodexEventMsg` with `images`/`local_images` | +| `src/cli/commands/assistants/chat/codexUploadsDetector.ts` | **Create** | Codex-specific rollout discovery and attachment extraction | +| `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts` | **Create** | Unit tests for the new detector | +| `src/cli/commands/assistants/chat/index.ts` | **Modify** | Add `CODEMIE_AGENT`-aware dispatch; import from `uploads-types.ts` | +| `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` | **Modify** | Add note about automatic session-based file detection | + +--- + +### Task 1: Extract shared types to `uploads-types.ts` + +**Files:** +- Create: `src/cli/commands/assistants/chat/uploads-types.ts` +- Modify: `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` (lines 9–22, 43–49, 247–348) +- Modify: `src/cli/commands/assistants/chat/index.ts` (line 24) + +**Interfaces:** +- Produces: `DetectedFile`, `readFilesFromPaths`, `MAX_FILE_SIZE_BYTES`, `BYTES_PER_KB`, `BYTES_PER_MB`, `DEFAULT_MEDIA_TYPE`, `logDetectedFiles` — all re-exported from `uploads-types.ts` +- Consumes: nothing new — pure refactor + +Test-first: **no** — this is a refactor; safety net is the existing `claudeUploadsDetector.test.ts` which tests `detectFileUploadsFromSession` and `readFilesFromPaths`. Run it after the rename to confirm no regression. + +- [ ] **Step 1: Create `uploads-types.ts`** + +```typescript +// src/cli/commands/assistants/chat/uploads-types.ts +import { existsSync, statSync, readFileSync } from 'fs'; +import { basename, resolve } from 'path'; +import chalk from 'chalk'; +import mime from 'mime-types'; +import { logger } from '@/utils/logger.js'; + +export const MAX_FILE_SIZE_MB = 100; +export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; +export const BYTES_PER_KB = 1024; +export const BYTES_PER_MB = 1024 * 1024; +export const DEFAULT_MEDIA_TYPE = 'application/octet-stream'; + +export interface DetectedFile { + fileName: string; + data: string; + mediaType: string; + type: 'image' | 'document'; + sizeBytes: number; +} + +export function logDetectedFiles(files: DetectedFile[], quiet: boolean): void { + if (files.length === 0 || quiet) return; + console.log(chalk.cyan(`\n📎 Detected ${files.length} file(s) with content:`)); + files.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); +} + +function detectMimeType(filePath: string): string { + return mime.lookup(filePath) || DEFAULT_MEDIA_TYPE; +} + +function detectFileType(mimeType: string): 'image' | 'document' { + return mimeType.startsWith('image/') ? 'image' : 'document'; +} + +/** + * Read files from disk and convert to DetectedFile format. + * Agent-agnostic; used by both Claude and Codex detection paths. + */ +export async function readFilesFromPaths( + filePaths: string[], + options: { quiet?: boolean } = {} +): Promise { + const { quiet = false } = options; + const detectedFiles: DetectedFile[] = []; + if (filePaths.length === 0) return []; + + logger.debug('[uploads-types] Reading files from paths', { + fileCount: filePaths.length, + paths: filePaths, + }); + + for (const filePath of filePaths) { + try { + const absolutePath = resolve(filePath); + if (!existsSync(absolutePath)) { + logger.warn('[uploads-types] File does not exist', { filePath: absolutePath }); + if (!quiet) console.log(chalk.yellow(`⚠ File not found: ${filePath}`)); + continue; + } + const stats = statSync(absolutePath); + if (!stats.isFile()) { + logger.warn('[uploads-types] Path is not a file', { filePath: absolutePath }); + if (!quiet) console.log(chalk.yellow(`⚠ Not a file: ${filePath}`)); + continue; + } + if (stats.size > MAX_FILE_SIZE_BYTES) { + logger.warn('[uploads-types] File exceeds size limit', { + filePath: absolutePath, + sizeMB: (stats.size / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + if (!quiet) console.log(chalk.yellow(`⚠ File too large (>${MAX_FILE_SIZE_MB}MB): ${filePath}`)); + continue; + } + const fileBuffer = readFileSync(absolutePath); + const base64Data = fileBuffer.toString('base64'); + const fileName = basename(absolutePath); + const mimeType = detectMimeType(absolutePath); + const fileType = detectFileType(mimeType); + detectedFiles.push({ + fileName, + data: base64Data, + mediaType: mimeType, + type: fileType, + sizeBytes: stats.size, + }); + logger.debug('[uploads-types] Read file from disk', { + fileName, + mediaType: mimeType, + type: fileType, + sizeMB: (stats.size / BYTES_PER_MB).toFixed(2), + }); + } catch (error) { + logger.warn('[uploads-types] Failed to read file', { filePath, error }); + if (!quiet) console.log(chalk.yellow(`⚠ Failed to read file: ${filePath}`)); + } + } + + if (!quiet && detectedFiles.length > 0) { + console.log(chalk.cyan(`\n📎 Loaded ${detectedFiles.length} file(s) from disk:`)); + detectedFiles.forEach((file, index) => { + const sizeKB = Math.round(file.sizeBytes / BYTES_PER_KB); + console.log(chalk.dim(` ${index + 1}. ${file.fileName} (${file.mediaType}, ${sizeKB} KB)`)); + }); + console.log(''); + } + + logger.debug('[uploads-types] Files read from disk', { + requestedCount: filePaths.length, + successCount: detectedFiles.length, + }); + + return detectedFiles; +} +``` + +- [ ] **Step 2: Trim `claudeUploadsDetector.ts` — remove duplicated symbols** + +In `claudeUploadsDetector.ts`, delete: +- The `import { statSync }` from `fs` (if only used by `readFilesFromPaths`) +- `import mime from 'mime-types'` (if only used by `readFilesFromPaths`) +- Constants: `MAX_FILE_SIZE_MB`, `MAX_FILE_SIZE_BYTES`, `BYTES_PER_KB`, `BYTES_PER_MB`, `DEFAULT_MEDIA_TYPE` +- The `DetectedFile` interface (lines 43–49) +- Functions `logDetectedFiles`, `readFilesFromPaths`, `detectMimeType`, `detectFileType` (lines 216–348) + +Add at the top of `claudeUploadsDetector.ts` (after existing imports): +```typescript +import { + type DetectedFile, + readFilesFromPaths, + logDetectedFiles, + MAX_FILE_SIZE_BYTES, + BYTES_PER_MB, + DEFAULT_MEDIA_TYPE, +} from './uploads-types.js'; +``` + +Keep the `export type { DetectedFile }` and `export { readFilesFromPaths }` at module scope so existing callers (`index.ts`) see no import change yet. + +Actually add explicit re-exports at the bottom of the trimmed file: +```typescript +export type { DetectedFile }; +export { readFilesFromPaths }; +``` + +- [ ] **Step 3: Run existing Claude detector tests to verify no regression** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/cli/commands/assistants/chat/uploads-types.ts \ + src/cli/commands/assistants/chat/claudeUploadsDetector.ts +git commit -m "refactor(assistants): extract DetectedFile and readFilesFromPaths to uploads-types.ts" +``` + +--- + +### Task 2: Add Codex message content types to `codex-message-types.ts` + +**Files:** +- Modify: `src/agents/plugins/codex/codex-message-types.ts` + +**Interfaces:** +- Produces: `CodexResponseItemMessage`, `CodexContentBlock`, and extended `CodexEventMsg` with `images?: string[]`, `local_images?: string[]` + +Test-first: **no** — type-only change; compiler verifies correctness when Task 3 uses these types. + +- [ ] **Step 1: Add new interfaces after `CodexResponseItem` (line 60)** + +Add after `CodexResponseItem`: +```typescript +/** + * The `message` sub-type of a `response_item` record — carries user/assistant + * content including images and documents. Not modelled in the base + * CodexResponseItem because that interface focuses on function-call shapes. + */ +export interface CodexResponseItemMessage { + type: 'message'; + role: 'user' | 'assistant'; + content: CodexContentBlock[]; +} + +/** Content block within a CodexResponseItemMessage.content array. */ +export interface CodexContentBlock { + type: 'input_text' | 'input_image' | 'input_file' | string; + text?: string; + /** Data URI: "data:;base64," — only on input_image blocks. */ + image_url?: string; +} +``` + +- [ ] **Step 2: Extend `CodexEventMsg` with user-message attachment fields** + +In `CodexEventMsg`, after `message?: string;`, add: +```typescript + /** Base64 image data URIs — always empty in practice; base64 lives in response_item. */ + images?: string[]; + /** Temp file paths on disk for attached images — basename is the filename fallback. */ + local_images?: string[]; + text_elements?: Array<{ + byte_range?: { start: number; end: number }; + placeholder?: string; + }>; +``` + +- [ ] **Step 3: Run typecheck to verify no breakage** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/agents/plugins/codex/codex-message-types.ts +git commit -m "feat(codex): add CodexResponseItemMessage and CodexContentBlock types for attachment extraction" +``` + +--- + +### Task 3: Implement `codexUploadsDetector.ts` + +**Files:** +- Create: `src/cli/commands/assistants/chat/codexUploadsDetector.ts` +- Create: `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts` + +**Interfaces:** +- Consumes: `DetectedFile` from `./uploads-types.js`; `CodexRolloutRecord`, `CodexSessionMeta`, `CodexEventMsg`, `CodexResponseItemMessage`, `CodexContentBlock` from `@/agents/plugins/codex/codex-message-types.js`; `getCodexDiscoverySessionRoots` from `@/agents/plugins/codex/codex.paths.js`; `readJSONLTolerant` from `@/agents/core/session/utils/jsonl-reader.js`; `isCodexInjectedUserText` from `@/agents/plugins/codex/session/codex-user-prompt.js` +- Produces: `detectCodexFileUploads({ cwd: string, quiet?: boolean }): Promise` + +Test-first: **yes** — write the test with a synthetic rollout containing one `input_image` block; it must fail (function not found) before implementation. + +- [ ] **Step 1: Write the failing test** + +Create `src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts`: + +```typescript +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Must mock BEFORE importing the module under test (dynamic-import pattern required by Vitest) +vi.mock('@/agents/plugins/codex/codex.paths.js', () => ({ + getCodexDiscoverySessionRoots: vi.fn(), +})); +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readdir: vi.fn(), + stat: vi.fn(), + }; +}); +vi.mock('@/agents/core/session/utils/jsonl-reader.js', () => ({ + readJSONLTolerant: vi.fn(), +})); +vi.mock('@/utils/logger.js', () => ({ + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +describe('detectCodexFileUploads', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns [] when no session directories found', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + expect(result).toEqual([]); + }); + + it('extracts input_image block from a matching rollout', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + const { readdir, stat } = await import('fs/promises'); + const { readJSONLTolerant } = await import('@/agents/core/session/utils/jsonl-reader.js'); + + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([ + { sessionsPath: '/fake/.codex/sessions', agentName: 'codex' }, + ]); + + // Fake directory scan: one day dir, one rollout file + vi.mocked(readdir).mockImplementation(async (p: unknown) => { + const path = p as string; + if (path === '/fake/.codex/sessions') return ['2026'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/2026')) return ['08'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/08')) return ['05'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/05')) return ['rollout-2026-08-05T10:00:00.000Z-abc123.jsonl'] as unknown as import('fs').Dirent[]; + return [] as unknown as import('fs').Dirent[]; + }); + vi.mocked(stat).mockResolvedValue({ isDirectory: () => path.endsWith('/2026') || path.endsWith('/08') || path.endsWith('/05'), mtime: new Date(2026, 7, 5, 10, 0, 0) } as import('fs').Stats); + + // Rollout content: session_meta with matching CWD + user response_item with input_image + event_msg + const fakeBase64 = Buffer.from('PNG_FAKE_DATA').toString('base64'); + vi.mocked(readJSONLTolerant).mockResolvedValue([ + { + type: 'session_meta', + payload: { id: 'abc123', timestamp: '2026-08-05T10:00:00Z', cwd: '/project' }, + }, + { + type: 'event_msg', + payload: { + type: 'user_message', + message: '$codemie-jira-assistant [Image #1]', + images: [], + local_images: ['/var/tmp/codex-clipboard-abc.png'], + turn_id: 'turn-1', + }, + }, + { + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: `data:image/png;base64,${fakeBase64}` }, + { type: 'input_text', text: '' }, + { type: 'input_text', text: '$codemie-jira-assistant [Image #1]' }, + ], + }, + }, + ]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + + expect(result).toHaveLength(1); + expect(result[0].fileName).toBe('codex-clipboard-abc.png'); + expect(result[0].mediaType).toBe('image/png'); + expect(result[0].type).toBe('image'); + expect(result[0].data).toBe(fakeBase64); + }); + + it('returns [] when rollout CWD does not match', async () => { + const { getCodexDiscoverySessionRoots } = await import('@/agents/plugins/codex/codex.paths.js'); + const { readdir, stat } = await import('fs/promises'); + const { readJSONLTolerant } = await import('@/agents/core/session/utils/jsonl-reader.js'); + + vi.mocked(getCodexDiscoverySessionRoots).mockReturnValue([ + { sessionsPath: '/fake/.codex/sessions', agentName: 'codex' }, + ]); + vi.mocked(readdir).mockImplementation(async (p: unknown) => { + const path = p as string; + if (path === '/fake/.codex/sessions') return ['2026'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/2026')) return ['08'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/08')) return ['05'] as unknown as import('fs').Dirent[]; + if (path.endsWith('/05')) return ['rollout-2026-08-05T10:00:00.000Z-abc123.jsonl'] as unknown as import('fs').Dirent[]; + return [] as unknown as import('fs').Dirent[]; + }); + vi.mocked(stat).mockResolvedValue({ isDirectory: () => false, mtime: new Date() } as import('fs').Stats); + + vi.mocked(readJSONLTolerant).mockResolvedValue([ + { + type: 'session_meta', + payload: { id: 'abc123', timestamp: '2026-08-05T10:00:00Z', cwd: '/other/project' }, + }, + ]); + + const { detectCodexFileUploads } = await import('../codexUploadsDetector.js'); + const result = await detectCodexFileUploads({ cwd: '/project', quiet: true }); + expect(result).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails (RED)** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +``` + +Expected: `Cannot find module '../codexUploadsDetector.js'` or similar import error. + +- [ ] **Step 3: Implement `codexUploadsDetector.ts`** + +Create `src/cli/commands/assistants/chat/codexUploadsDetector.ts`: + +```typescript +/** + * Codex-specific file attachment detector. + * + * Discovers the active Codex rollout file by scanning the Codex sessions + * directories for a rollout whose session_meta.cwd matches process.cwd(), + * then extracts input_image / input_file blocks from the most recent user + * response_item record. + * + * Does NOT use CodexSessionAdapter (which requires AgentMetadata) — rollout + * discovery is implemented directly via getCodexDiscoverySessionRoots() + + * readJSONLTolerant to keep the CLI command layer dependency-light. + */ + +import { realpath as fsRealpath, readdir, stat } from 'fs/promises'; +import { basename, join } from 'path'; +import chalk from 'chalk'; +import { logger } from '@/utils/logger.js'; +import { readJSONLTolerant } from '@/agents/core/session/utils/jsonl-reader.js'; +import { getCodexDiscoverySessionRoots } from '@/agents/plugins/codex/codex.paths.js'; +import { isCodexInjectedUserText } from '@/agents/plugins/codex/session/codex-user-prompt.js'; +import type { + CodexRolloutRecord, + CodexSessionMeta, + CodexEventMsg, + CodexResponseItemMessage, + CodexContentBlock, +} from '@/agents/plugins/codex/codex-message-types.js'; +import { + type DetectedFile, + logDetectedFiles, + MAX_FILE_SIZE_BYTES, + MAX_FILE_SIZE_MB, + BYTES_PER_MB, + DEFAULT_MEDIA_TYPE, +} from './uploads-types.js'; + +const LOG_PREFIX = '[codexUploadsDetector]'; +const ROLLOUT_FILE_PATTERN = /^rollout-.*\.jsonl$/; +const IMAGE_WRAPPER_PATTERN = //; +const MAX_ROLLOUT_AGE_DAYS = 1; +const MS_PER_DAY = 86_400_000; + +export interface DetectCodexFileUploadsOptions { + cwd: string; + quiet?: boolean; +} + +async function safeRealpath(p: string): Promise { + try { + return await fsRealpath(p); + } catch { + return p; + } +} + +/** + * Scan Codex session day-directory for rollout files, returning descriptors + * sorted newest-first. + */ +async function discoverRolloutFiles( + sessionsPath: string, + cutoffMs: number +): Promise> { + const results: Array<{ filePath: string; mtime: number }> = []; + + let yearDirs: string[]; + try { + yearDirs = await readdir(sessionsPath); + } catch { + return results; + } + + for (const yearDir of yearDirs) { + const yearPath = join(sessionsPath, yearDir); + let monthDirs: string[]; + try { + monthDirs = await readdir(yearPath); + } catch { continue; } + + for (const monthDir of monthDirs) { + const monthPath = join(yearPath, monthDir); + let dayDirs: string[]; + try { + dayDirs = await readdir(monthPath); + } catch { continue; } + + for (const dayDir of dayDirs) { + const dayPath = join(monthPath, dayDir); + let files: string[]; + try { + files = await readdir(dayPath); + } catch { continue; } + + for (const file of files) { + if (!ROLLOUT_FILE_PATTERN.test(file)) continue; + const filePath = join(dayPath, file); + try { + const s = await stat(filePath); + if (s.mtime.getTime() >= cutoffMs) { + results.push({ filePath, mtime: s.mtime.getTime() }); + } + } catch { /* skip unreadable */ } + } + } + } + } + + results.sort((a, b) => b.mtime - a.mtime); + return results; +} + +/** + * Find the rollout file whose session_meta.cwd resolves to cwdReal. + * Returns the file path of the newest matching rollout, or null. + */ +async function findMatchingRollout(cwdReal: string): Promise { + const roots = getCodexDiscoverySessionRoots(); + if (!roots.length) { + logger.debug(`${LOG_PREFIX} No Codex session directories found`); + return null; + } + + const cutoffMs = Date.now() - MAX_ROLLOUT_AGE_DAYS * MS_PER_DAY; + const allCandidates: Array<{ filePath: string; mtime: number }> = []; + + for (const root of roots) { + const candidates = await discoverRolloutFiles(root.sessionsPath, cutoffMs); + allCandidates.push(...candidates); + } + + allCandidates.sort((a, b) => b.mtime - a.mtime); + + for (const { filePath } of allCandidates) { + const records = await readJSONLTolerant(filePath, LOG_PREFIX); + const metaRecord = records.find((r) => r.type === 'session_meta'); + if (!metaRecord) continue; + + const sessionMeta = metaRecord.payload as CodexSessionMeta; + const metaReal = await safeRealpath(sessionMeta.cwd); + + if (metaReal === cwdReal) { + logger.debug(`${LOG_PREFIX} Matched rollout`, { filePath, cwd: sessionMeta.cwd }); + return filePath; + } + } + + return null; +} + +/** + * Process a single input_image content block into a DetectedFile. + * Returns null when the block is invalid or too large. + */ +function processImageBlock( + block: CodexContentBlock, + fileName: string +): DetectedFile | null { + if (!block.image_url) { + logger.warn(`${LOG_PREFIX} input_image block missing image_url`, { fileName }); + return null; + } + + const commaIdx = block.image_url.indexOf(','); + if (commaIdx === -1) { + logger.warn(`${LOG_PREFIX} Malformed data URI in input_image block`, { fileName }); + return null; + } + + const prefix = block.image_url.slice(0, commaIdx); + const base64Data = block.image_url.slice(commaIdx + 1); + const mimeMatch = /data:([^;]+);base64/.exec(prefix); + const mediaType = mimeMatch?.[1] ?? DEFAULT_MEDIA_TYPE; + + try { + const fileSize = Buffer.from(base64Data, 'base64').length; + if (fileSize > MAX_FILE_SIZE_BYTES) { + logger.warn(`${LOG_PREFIX} File exceeds size limit, skipping`, { + fileName, + sizeMB: (fileSize / BYTES_PER_MB).toFixed(2), + limit: MAX_FILE_SIZE_MB, + }); + return null; + } + + return { + fileName, + data: base64Data, + mediaType, + type: 'image', + sizeBytes: fileSize, + }; + } catch (error) { + logger.warn(`${LOG_PREFIX} Invalid base64 data`, { fileName, error }); + return null; + } +} + +/** + * Extract attached files from a Codex rollout record array. + * + * Algorithm: + * 1. Scan backward for the last event_msg with type user_message that is + * not injected context — captures local_images for filename fallback. + * 2. Scan backward for the last response_item with role user that has + * input_image/input_file blocks and is not purely injected text. + * 3. Walk the content array; for each input_image block, extract filename + * from the preceding text wrapper (or + * local_images[i] as fallback) and parse the data URI. + */ +function extractAttachmentsFromRecords(records: CodexRolloutRecord[]): DetectedFile[] { + let targetResponseItem: CodexResponseItemMessage | null = null; + let targetEventMsg: (CodexEventMsg & { local_images?: string[] }) | null = null; + + // Single backward pass: find both anchor records + for (let i = records.length - 1; i >= 0; i--) { + const record = records[i]; + + if (!targetResponseItem && record.type === 'response_item') { + const payload = record.payload as unknown as CodexResponseItemMessage; + if ( + payload.type === 'message' && + payload.role === 'user' && + Array.isArray(payload.content) && + payload.content.some((b) => b.type === 'input_image' || b.type === 'input_file') + ) { + // Confirm the user text is not entirely injected + const userText = payload.content + .filter((b): b is CodexContentBlock & { text: string } => + b.type === 'input_text' && typeof b.text === 'string' + ) + .map((b) => b.text) + .join(' ') + .trim(); + if (!userText || !isCodexInjectedUserText(userText)) { + targetResponseItem = payload; + } + } + } + + if (!targetEventMsg && record.type === 'event_msg') { + const payload = record.payload as CodexEventMsg & { local_images?: string[] }; + if ( + payload.type === 'user_message' && + typeof payload.message === 'string' && + !isCodexInjectedUserText(payload.message) + ) { + targetEventMsg = payload; + } + } + + if (targetResponseItem && targetEventMsg) break; + } + + if (!targetResponseItem) { + logger.debug(`${LOG_PREFIX} No user response_item with attachments found`); + return []; + } + + const detectedFiles: DetectedFile[] = []; + const content = targetResponseItem.content; + let localImageIndex = 0; + + for (let i = 0; i < content.length; i++) { + const block = content[i]; + if (block.type !== 'input_image' && block.type !== 'input_file') continue; + + // Filename: check preceding wrapper + let fileName: string | undefined; + if (i > 0) { + const prev = content[i - 1]; + if (prev.type === 'input_text' && typeof prev.text === 'string') { + const match = IMAGE_WRAPPER_PATTERN.exec(prev.text); + if (match) fileName = basename(match[1]); + } + } + // Fallback to local_images basename + if (!fileName) { + const localPath = targetEventMsg?.local_images?.[localImageIndex]; + if (localPath) fileName = basename(localPath); + } + fileName = fileName || `attachment_${localImageIndex}`; + localImageIndex++; + + if (block.type === 'input_image') { + const detectedFile = processImageBlock(block, fileName); + if (detectedFile) detectedFiles.push(detectedFile); + } + // input_file handling can be added here in future iterations + } + + logger.debug(`${LOG_PREFIX} Extracted attachments`, { + count: detectedFiles.length, + }); + + return detectedFiles; +} + +/** + * Detect file uploads from the active Codex rollout. + * + * Finds the most recent rollout matching the given CWD, then extracts any + * files attached in the most recent user turn. + * + * @param options.cwd Project directory (typically process.cwd()) + * @param options.quiet Suppress console output when true + */ +export async function detectCodexFileUploads( + options: DetectCodexFileUploadsOptions +): Promise { + const { cwd, quiet = false } = options; + + logger.debug(`${LOG_PREFIX} Detecting file uploads from Codex rollout`, { cwd }); + + try { + const cwdReal = await safeRealpath(cwd); + const rolloutPath = await findMatchingRollout(cwdReal); + + if (!rolloutPath) { + logger.debug(`${LOG_PREFIX} No matching rollout found for cwd`, { cwdReal }); + return []; + } + + const records = await readJSONLTolerant(rolloutPath, LOG_PREFIX); + const detectedFiles = extractAttachmentsFromRecords(records); + + logDetectedFiles(detectedFiles, quiet); + + logger.debug(`${LOG_PREFIX} Detection complete`, { + filesDetected: detectedFiles.length, + }); + + return detectedFiles; + } catch (error) { + logger.debug(`${LOG_PREFIX} Failed to detect file uploads`, { error }); + return []; + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass (GREEN)** + +```bash +npx vitest run src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Typecheck** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 6: Commit** + +```bash +git add src/cli/commands/assistants/chat/codexUploadsDetector.ts \ + src/cli/commands/assistants/chat/__tests__/codexUploadsDetector.test.ts +git commit -m "feat(assistants): implement codexUploadsDetector for Codex rollout attachment extraction" +``` + +--- + +### Task 4: Wire agent-aware dispatch in `chat/index.ts` + +**Files:** +- Modify: `src/cli/commands/assistants/chat/index.ts` + +**Interfaces:** +- Consumes: `detectCodexFileUploads` from `./codexUploadsDetector.js`; `DetectedFile`, `readFilesFromPaths` from `./uploads-types.js` +- No change to `detectFileUploadsFromSession` import from `./claudeUploadsDetector.js` + +Test-first: **no** — simple conditional branch; verified by typecheck and the integration path visible in existing architecture. + +- [ ] **Step 1: Update the import on line 24** + +Replace: +```typescript +import { detectFileUploadsFromSession, readFilesFromPaths, type DetectedFile } from './claudeUploadsDetector.js'; +``` +With: +```typescript +import { detectFileUploadsFromSession } from './claudeUploadsDetector.js'; +import { detectCodexFileUploads } from './codexUploadsDetector.js'; +import { readFilesFromPaths, type DetectedFile } from './uploads-types.js'; +``` + +- [ ] **Step 2: Replace the file detection block (lines 98–104)** + +Replace: +```typescript + // 1. Detect files from the Claude session (always use CODEMIE_SESSION_ID, not --conversation-id). + // --conversation-id identifies the assistant chat thread; CODEMIE_SESSION_ID identifies the + // Claude session whose JSONL contains the uploaded file blobs. + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } +``` +With: +```typescript + // 1. Detect files from the agent session. + // CODEMIE_AGENT selects the detection strategy: + // - 'codex': scan Codex rollout JSONL via CWD match (hooks non-functional in Codex) + // - default: read Claude session JSONL via CODEMIE_SESSION_ID → correlation → agentSessionFile + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else { + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } + } +``` + +- [ ] **Step 3: Typecheck** + +```bash +npm run typecheck +``` + +Expected: exits 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/cli/commands/assistants/chat/index.ts +git commit -m "feat(assistants): add CODEMIE_AGENT-aware file detection dispatch for Codex" +``` + +--- + +### Task 5: Update `codex-skill-generator.ts` — add file attachment note + +**Files:** +- Modify: `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` + +**Interfaces:** +- No interface changes — template update only + +Test-first: **no** — string template; no logic. + +- [ ] **Step 1: Update the skill template in `createSkillContent` to mention automatic detection** + +In `createSkillContent` (line 30), update the instructions section. After the existing step 4 (`After any write, re-fetch…`), add a step 5 about file attachments: + +Locate the current text at the end of `dedent\`` block (after step 4): +```typescript + Run CodeMie assistant chat with the user's message: +``` + +Before that line add step 5: +```typescript + 5. **File attachments are automatically detected** — if the user attaches an image or document when invoking this skill, CodeMie will detect it from the Codex session rollout automatically. You do not need to pass \`CODEMIE_SESSION_ID\` or any attachment flag explicitly; the \`codemie assistants chat\` command handles it. Use \`--file\` only when you want to attach a file from the filesystem that was NOT dragged into the Codex session. + +``` + +- [ ] **Step 2: Typecheck and lint** + +```bash +npm run typecheck && npm run lint +``` + +Expected: exits 0. + +- [ ] **Step 3: Commit** + +```bash +git add src/cli/commands/assistants/setup/generators/codex-skill-generator.ts +git commit -m "docs(assistants): note automatic file detection in Codex skill template" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** + +| AC | Task | +|---|---| +| Investigation completed on how Codex stores file attachments | Research pre-completed (format analysis doc); documented in `codexUploadsDetector.ts` header | +| Extend file attachments to support Codex | Task 3 + Task 4 | +| User can invoke via `/slug` or `@slug` with a file attached | Task 4 dispatch wires up; skill template updated in Task 5 | +| Message and file attachment sent to CodeMie correctly | `uploadFilesToCodeMie` and `sendMessageWithHistory` unchanged; `DetectedFile` contract preserved | +| Attachment support works for Codex assistants from `codemie setup assistants` | Task 5 skill generator update | +| No regression for Claude | Task 1 preserves Claude path via `else` branch; existing tests remain | +| No regression for invocations without attachments | `detectCodexFileUploads` returns `[]` when no rollout found | +| Codex-specific constraints documented | `codexUploadsDetector.ts` module docstring; type extensions in `codex-message-types.ts` | + +**Placeholder scan:** No TBDs, no "similar to" references, all code blocks complete. + +**Type consistency:** +- `DetectedFile` defined in `uploads-types.ts`, re-exported from `claudeUploadsDetector.ts`, imported in `index.ts` from `uploads-types.ts` directly. +- `detectCodexFileUploads` signature: `(options: DetectCodexFileUploadsOptions): Promise` — matches the call site in `index.ts`. +- `CodexResponseItemMessage.content: CodexContentBlock[]` — used in detector; consistent with type definitions in Task 2. diff --git a/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md new file mode 100644 index 000000000..45f22ffc3 --- /dev/null +++ b/docs/superpowers/tasks/2026-08-05-epmcdme-13885-codex-file-attachments/technical-analysis.md @@ -0,0 +1,270 @@ +# Technical Research + +**Task**: codex uploads detector assistants chat session correlation +**Generated**: 2026-08-05T00:00:00Z +**Research path**: codegraph + filesystem + +--- + +## 1. Original Context + +**Ticket:** EPMCDME-13885 — CodeMie CLI (Codex): implement file attachments support for CodeMie assistants + +**Description:** +Extend the current file attachments implementation used for CodeMie assistant invocation so that it also supports Codex. Users can register assistants for Codex via `codemie setup assistants` and invoke them via `/slug` or `@slug`. This task ensures that when a user invokes a configured CodeMie assistant from Codex with a file, the attachment is correctly collected, transferred to CodeMie, and handled by the assistant. This improves feature parity across supported CLI agents. + +**Acceptance criteria:** +- Investigation is completed to determine how Codex stores and exposes file attachments for assistant invocation flows. +- The current file attachments implementation (currently works for Claude) is extended to support Codex as well. +- A user can invoke a configured CodeMie assistant from Codex via `/slug` or `@slug` with a file attached. +- The message and file attachment are sent to CodeMie and handled correctly by the assistant. +- Attachment support works for Codex assistant flows created via `codemie setup assistants`. +- No regression is introduced for existing attachment support in Claude. +- No regression is introduced for assistant invocation without attachments. + +--- + +## 2. Codebase Findings + +### Existing Implementations + +**Claude upload detector (reference implementation):** +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` — Claude-exclusive. Entry point: `detectFileUploadsFromSession(sessionId, options)`. Internal flow: + 1. `readSessionMetadata(sessionId)` → reads `~/.codemie/sessions/{id}.json` → `Session` + 2. `extractAgentSessionFile(session)` → reads `session.correlation.agentSessionFile` (requires `status === 'matched'`) + 3. `readJSONL(agentSessionFile)` → parses the Claude JSONL transcript + 4. `extractFileContentFromMessages(messages)` → finds most recent non-meta user message, captures its `promptId`, collects `isMeta` messages with the same `promptId`, extracts `image`/`document` content items where `source.type === 'base64'` and `source.data` holds the raw base64 string + 5. Returns `DetectedFile[]` + +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` also exports: + - `readFilesFromPaths(filePaths, options)` → reads files from `--file` CLI paths; shared by both Claude and Codex code paths + - `DetectedFile` interface: `{ fileName, data, mediaType, type: 'image' | 'document', sizeBytes }` + +**Chat orchestration (integration point):** +- `src/cli/commands/assistants/chat/index.ts` — key section (lines 100–110): + ```typescript + const claudeSessionId = process.env.CODEMIE_SESSION_ID; + if (claudeSessionId) { + detectedFiles = await detectFileUploadsFromSession(claudeSessionId, { quiet: false }); + } + if (options.file && options.file.length > 0) { + const filesFromPaths = await readFilesFromPaths(options.file, { quiet: false }); + detectedFiles = [...detectedFiles, ...filesFromPaths]; + } + ``` + - This code does NOT branch on agent type. For Codex the same `CODEMIE_SESSION_ID` env var is checked, but `detectFileUploadsFromSession` will silently return `[]` because `session.correlation.agentSessionFile` is never populated for Codex (hooks are non-functional). + - `uploadFilesToCodeMie(client, files)` → calls `client.files.bulkUpload(FileToUpload[])` → returns `string[]` (file URLs) + - `sendMessageWithHistory` passes `file_names: fileUrls` to `client.assistants.chat()` + +**Codex plugin:** +- `src/agents/plugins/codex/codex.plugin.ts` — `onSessionStart(sessionId, env)`: + - Calls `processEvent(SessionStart)` → creates `~/.codemie/sessions/{id}.json` with `correlation: { status: 'pending', retryCount: 0 }` + - Calls `startCodexIncrementalSync({ sessionId, startedAt, cwd, ... })` + - Does NOT populate `session.correlation.agentSessionFile` — this is never set for Codex sessions + - The comment block at lines 27–35 explicitly documents that Codex hooks are non-functional + +- `src/agents/plugins/codex/codex.incremental-sync.ts` — timer tick pattern for rollout discovery: + ```typescript + const adapter = new CodexSessionAdapter(options.metadata); + const sessions = await adapter.discoverSessions({ maxAgeDays: 1, limit: 10 }); + for (const descriptor of sessions) { + if (descriptor.createdAt < options.startedAt - STARTED_AT_GRACE_MS) continue; + const parsed = await adapter.parseSessionFile(descriptor.filePath, options.sessionId); + const projectPath = parsed.metadata?.projectPath; + const projectReal = await safeRealpath(projectPath); + if (projectReal !== cwdReal) continue; + // process this rollout + } + ``` + This is the canonical pattern for finding the active rollout matching the current CWD. The new `codexUploadsDetector.ts` must use the same pattern. + +**Codex session adapter:** +- `src/agents/plugins/codex/codex.session.ts` — `CodexSessionAdapter`: + - `discoverSessions({ maxAgeDays, limit })` → scans `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` and `~/.codex/codemie/home/sessions/YYYY/MM/DD/rollout-*.jsonl`; returns `SessionDescriptor[]` sorted newest-first + - `parseSessionFile(filePath, sessionId)` → reads rollout JSONL, extracts `session_meta`/`turn_context`; returns parsed metadata including `metadata.projectPath` (the CWD when Codex started) + +**Codex message types:** +- `src/agents/plugins/codex/codex-message-types.ts` — defines `CodexLine` discriminated union; contains `response_item` and `event_msg` record shapes + +**Codex user prompt parser:** +- `src/agents/plugins/codex/session/codex-user-prompt.ts` — `firstCodexUserText()` and `isCodexInjectedUserText()` demonstrate the rollout record shapes: + - `event_msg { payload.type === 'user_message', payload.message }` — text-only metadata + - `response_item { payload.type === 'message', payload.role === 'user', payload.content: ContentBlock[] }` — actual content with `input_text` / `input_image` / `input_file` blocks + +**Session types:** +- `src/agents/core/session/types.ts` — `Session.correlation: CorrelationResult`: + ```typescript + interface CorrelationResult { + status: CorrelationStatus; // 'pending' | 'matched' | 'failed' + agentSessionFile?: string; // Path to matched agent JSONL/rollout file + agentSessionId?: string; + detectedAt?: number; + retryCount: number; + } + ``` + For Codex, `status` is always `'pending'` and `agentSessionFile` is never set. + +**Utilities reusable by new detector:** +- `src/agents/core/session/utils/jsonl-reader.ts` — `readJSONL()` and `readJSONLTolerant()` — shared JSONL reader +- `src/cli/commands/assistants/chat/claudeUploadsDetector.ts` — `readFilesFromPaths()` export is already agent-agnostic and reusable + +--- + +### Architecture and Layers Affected + +| Layer | Component | Change required | +|---|---|---| +| CLI / Command | `src/cli/commands/assistants/chat/index.ts` | Add agent-aware dispatch: check `CODEMIE_AGENT` env var, call Codex detector when agent is `codex` | +| CLI / Detector | `src/cli/commands/assistants/chat/codexUploadsDetector.ts` | **New file** — Codex-specific rollout parsing analogous to `claudeUploadsDetector.ts` | +| Agent Plugin | `src/agents/plugins/codex/codex.plugin.ts` | `onSessionStart`: optionally store rollout discovery criteria; see risk notes | +| Core Session | `src/agents/core/session/types.ts` | No changes required; `CorrelationResult.agentSessionFile` shape is already sufficient | + +--- + +### Integration Points + +**Agent context detection in `chat/index.ts`:** +- `process.env.CODEMIE_AGENT` is set by `BaseAgentAdapter` from `metadata.name` before the child process runs. Inside a Codex skill invocation, `CODEMIE_AGENT === 'codex'`. +- The dispatch branch should be: + ```typescript + const agentName = process.env.CODEMIE_AGENT; + if (agentName === 'codex') { + detectedFiles = await detectCodexFileUploads({ cwd: process.cwd(), quiet: false }); + } else if (process.env.CODEMIE_SESSION_ID) { + detectedFiles = await detectFileUploadsFromSession(process.env.CODEMIE_SESSION_ID, { quiet: false }); + } + ``` + +**Rollout discovery in `codexUploadsDetector.ts`:** +- Use `CodexSessionAdapter.discoverSessions({ maxAgeDays: 1, limit: 10 })` + CWD realpath match (identical to incremental-sync tick pattern). +- No dependency on `session.correlation.agentSessionFile` — bypasses the broken hook correlation entirely. +- Input: current `process.cwd()` (the project directory where Codex was launched). +- Discovery must be tolerant of concurrent write: the rollout is being appended to as Codex runs. Use `readJSONLTolerant()`. + +**Turn identification in the rollout:** +- Find the most recent `turn_id` that has a `response_item` record with `payload.role === 'user'` containing `input_image` or `input_file` blocks. +- Image data: `block.image_url` is a data URI `"data:;base64,"` — extract MIME and base64 by splitting on `,`. +- Filename: extracted from the `` wrapper text block that precedes the `input_image` block in the same `content` array; fall back to `event_msg.local_images[i]` for the same `turn_id`. + +**SDK upload chain (unchanged):** +- `DetectedFile` returned by the new detector must match the existing interface exactly (same fields: `fileName`, `data`, `mediaType`, `type`, `sizeBytes`) so `uploadFilesToCodeMie()` and `sendMessageWithHistory()` require no changes. + +--- + +### Patterns and Conventions + +- Detector files in `src/cli/commands/assistants/chat/` follow the pattern: named `UploadsDetector.ts`, export a primary `detect*` async function returning `Promise`, use `logger.debug` for diagnostics and `chalk` for console output. +- All JSONL reading goes through `readJSONL` / `readJSONLTolerant` from `src/agents/core/session/utils/jsonl-reader.ts`. +- Realpath normalization via `fsRealpath` + `safeRealpath` fallback is required for CWD matching (macOS symlinks: `/Users/foo` ↔ `/private/Users/foo`). +- The `DetectedFile` interface is defined in `claudeUploadsDetector.ts` and imported into `index.ts`; the new Codex detector should import and reuse `DetectedFile` rather than redefining it. +- Agent plugin lifecycle methods (`onSessionStart`, `onSessionEnd`) use fire-and-forget error handling (`try/catch` with `logger.error`, never throwing). +- Constants (type strings, status codes) are extracted into `const` objects at module top level. + +--- + +## 3. Documentation Findings + +### Guides and Architecture Docs + +- `.ai-run/guides/architecture/architecture.md` — plugin-based 5-layer architecture; CLI layer dispatches to Provider/Plugin layer via Registry +- `.ai-run/guides/integration/external-integrations.md` — provider plugin patterns, SSO, agent adapters +- `.ai-run/guides/integration/exposed-api.md` — CLI surface, plugin contracts +- `docs/superpowers/plans/2026-05-09-codex-hooks-incremental-sync.md` — records the investigation confirming Codex hooks are non-functional; establishes the incremental-sync timer as the canonical workaround + +### Architectural Decisions + +- **ADR (inline, `codex.plugin.ts` lines 27–35):** Codex hooks advertised in 0.129.0 were confirmed non-firing on `codex exec`; timer-based incremental sync chosen as the workaround. +- **Decision (inline, `codex.plugin.ts` `beforeRun`):** CodeMie-managed Codex runs use `CODEX_HOME=~/.codex/codemie/home` to isolate state from native Codex. +- **Decision (inline, `codex.plugin.ts` `enrichArgs`):** Custom `model_providers.codemie` provider used to bypass `~/.codex/auth.json` precedence for OPENAI_API_KEY. +- **Decision (`claudeUploadsDetector.ts` comment):** Session detection always uses `CODEMIE_SESSION_ID` (not `--conversation-id`) because the two identify different things: the agent session vs the assistant chat thread. + +### Derived Conventions + +- New detector must be in `src/cli/commands/assistants/chat/` alongside `claudeUploadsDetector.ts`. +- Rollout discovery must use `CodexSessionAdapter` (not raw `fs.glob`) to inherit the multi-root scan logic and `SessionDescriptor` sorting. +- The `DetectedFile` interface defined in `claudeUploadsDetector.ts` is the shared contract; do not duplicate it. +- Agent-discriminating logic in `index.ts` must default to the Claude path so existing behavior is preserved when `CODEMIE_AGENT` is unset or `'claude'`. + +--- + +## 4. Testing Landscape + +### Existing Coverage + +- `src/cli/commands/assistants/chat/__tests__/claudeUploadsDetector.test.ts` — covers `detectFileUploadsFromSession` (3 call sites); uses fixture JSONL files; tests `isMeta`+`promptId` grouping logic. **No Codex analog exists yet.** +- `src/agents/plugins/codex/__tests__/codex.incremental-sync.test.ts` — covers `startCodexIncrementalSync` / `stopCodexIncrementalSync` with a fake adapter; does not test rollout-to-file extraction. +- `src/agents/plugins/codex/__tests__/codex.paths.test.ts` — covers `getCodexSessionDayPath`. +- `src/cli/commands/analytics/cost/__tests__/usage-readers.test.ts` — covers `extractCodexUsageRecords` using fixture rollout files at `tests/integration/session/fixtures/codex/`. + +### Testing Framework and Patterns + +- **Framework**: Vitest +- **Fixtures**: Codex rollout fixture files exist at `tests/integration/session/fixtures/codex/`. The new detector tests should add a fixture rollout JSONL containing `response_item` records with `input_image` blocks. +- **Mocking pattern**: dynamic imports after `vi.mock()` setup (per `.ai-run/guides/testing/testing-patterns.md`). +- **Claude detector test pattern**: mock `fs.existsSync`, `fs.readFileSync`, and the JSONL reader; supply synthetic `ClaudeMessage[]` arrays. The same approach applies to Codex tests using synthetic `CodexLine[]` arrays. + +### Coverage Gaps + +- `codexUploadsDetector.ts` — entire new module; zero coverage until tests are written +- `codex.plugin.ts` `onSessionStart` — no test for correlation store update if it is added +- `chat/index.ts` agent-dispatch branch — no test for `CODEMIE_AGENT === 'codex'` path + +--- + +## 5. Configuration and Environment + +### Environment Variables + +| Variable | Purpose | +|---|---| +| `CODEMIE_SESSION_ID` | CodeMie session ID set by hooks; used by Claude detector path; Codex does not set this reliably via hooks but `onSessionStart` creates the session file with this ID | +| `CODEMIE_AGENT` | Agent name string (`'codex'`, `'claude'`, etc.); set by `BaseAgentAdapter` from `metadata.name`; used by `chat/index.ts` to choose detector | +| `CODEX_HOME` | Codex home dir override; set to `~/.codex/codemie/home` by `beforeRun` for CodeMie-managed runs | +| `CODEMIE_CODEX_SYNC_ENABLED` | Set to `'false'` to disable incremental sync | +| `CODEMIE_CODEX_SYNC_INTERVAL_MS` | Sync interval override (default 30 000 ms) | + +### Configuration Files + +- `~/.codemie/sessions/{id}.json` — `Session` object; `correlation.agentSessionFile` is populated for Claude, not for Codex +- `~/.codex/sessions/YYYY/MM/DD/rollout-{ISO8601}-{uuid}.jsonl` — Codex rollout (native CODEX_HOME) +- `~/.codex/codemie/home/sessions/YYYY/MM/DD/rollout-*.jsonl` — Codex rollout (CodeMie-managed CODEX_HOME) +- `~/.codex/skills/{slug}/SKILL.md` — generated by `codex-skill-generator.ts`; current template instructs agent to use `--file`; no session-based detection mentioned + +### Feature Flags and Deployment Concerns + +- No feature flags gate this functionality; the agent-dispatch branch in `index.ts` is the only toggle (presence of `CODEMIE_AGENT`). +- The generated Codex skill template in `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` currently tells the skill "do NOT use `CODEMIE_SESSION_ID` as a fallback" — this note may become stale or misleading once session-based detection is in place and should be reviewed. + +--- + +## 6. Risk Indicators + +- **No `session.correlation.agentSessionFile` for Codex** — `onSessionStart` creates the session file with `status: 'pending'`, and `agentSessionFile` is never set because hooks do not fire. The new detector must bypass `extractAgentSessionFile()` entirely and discover the rollout directly via `CodexSessionAdapter.discoverSessions()` + CWD matching, identical to the incremental-sync tick pattern. + +- **Rollout timing race** — at the moment `codemie assistants chat` is invoked from a Codex skill, the rollout file is still being written (Codex is running). The detector must use `readJSONLTolerant()` rather than `readJSONL()` to tolerate incomplete/truncated trailing lines. + +- **Rollout path ambiguity (dual CODEX_HOME)** — `CodexSessionAdapter.discoverSessions()` already scans both `~/.codex/sessions` and `~/.codex/codemie/home/sessions`. Multiple rollout files may match the same CWD + time window. The detector must take the newest match only (descriptors are already sorted newest-first by the adapter). + +- **Different base64 encoding format** — Claude: `item.source.data` (raw base64 string). Codex: `block.image_url = "data:;base64,"` (data URI). The new detector must split on the first `,` to separate MIME from base64. The MIME value from the data URI overrides any mime-types lookup. + +- **`event_msg.images` is always empty** — base64 is never stored in `event_msg`. Filename can be recovered from `event_msg.local_images[i]` (temp file path) or from the `` text wrapper in the `response_item`. The two records share the same `turn_id`; the new detector must correlate by `turn_id`. + +- **`turn_id` location** — based on pre-completed research, `turn_id` is carried in `internal_chat_message_metadata_passthrough`. This field name must be confirmed against `codex-message-types.ts` before implementation; the type definitions are the authoritative source. + +- **No existing test coverage for the new code surface** — `codexUploadsDetector.ts` starts with zero tests; the agent-dispatch branch in `index.ts` has no test. A fixture rollout JSONL with `input_image` blocks is needed (can be derived from existing fixtures at `tests/integration/session/fixtures/codex/`). + +- **Codex skill generator stale note** — `src/cli/commands/assistants/setup/generators/codex-skill-generator.ts` currently advises against using `CODEMIE_SESSION_ID`. Once session-based detection is transparent (no env var required), that note may confuse future maintainers; it should be removed or updated. + +- **`CODEMIE_AGENT` set by `BaseAgentAdapter`** — the agent-type check in `chat/index.ts` depends on `CODEMIE_AGENT` being present. Confirm `BaseAgentAdapter` sets it unconditionally before Codex launches; if not, the guard must fall through to the Claude path silently (not throw). + +- **`DetectedFile` import coupling** — `DetectedFile` is currently defined inside `claudeUploadsDetector.ts` and imported from there by `index.ts`. If the Codex detector imports `DetectedFile` from `claudeUploadsDetector.ts`, a circular-import risk exists if `claudeUploadsDetector.ts` is ever changed to import from the Codex side. Safest: export `DetectedFile` and `readFilesFromPaths` from a shared `uploads-types.ts` in the same directory. + +--- + +## 7. Summary for Complexity Assessment + +This task touches three architectural layers: the CLI/Command layer (`chat/index.ts`), a new CLI/Detector module (`codexUploadsDetector.ts`), and the Agent Plugin layer (`codex.plugin.ts`). The estimated file change surface is 3–4 files: one new file, two modified files, and potentially a shared types extraction. The SDK upload chain (`uploadFilesToCodeMie`, `client.files.bulkUpload`) and the `DetectedFile` contract require no changes, which significantly bounds the blast radius. + +The task introduces one genuinely novel pattern: rollout-based attachment extraction. The incremental-sync tick already demonstrates the rollout discovery algorithm (CWD realpath match, adapter.discoverSessions, parseSessionFile), so the core discovery logic can be cloned with minor adaptation. The novel piece is parsing the OpenAI Responses API content block format (`input_image` with data URI `image_url`) instead of the Claude base64 `source.data` shape. This requires careful handling of the data URI split and turn-based grouping using `turn_id`/`internal_chat_message_metadata_passthrough`. The exact field path for `turn_id` must be confirmed against `codex-message-types.ts` before coding — this is the primary investigation step remaining. + +Test coverage posture is weak for the new code: no existing test covers `codexUploadsDetector.ts` (it doesn't exist yet), and the agent-dispatch branch in `index.ts` is untested. Existing fixtures at `tests/integration/session/fixtures/codex/` provide a starting point, but a new fixture rollout containing `response_item` + `input_image` blocks must be created. The absence of test infrastructure for the new detector path is the most significant risk factor for regression — both for the Codex path and for inadvertent breakage of the Claude path if `detectFileUploadsFromSession` import or the dispatch logic is touched incorrectly.