diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md index 4373ff9..42a7abf 100644 --- a/prompts/claude-platform.md +++ b/prompts/claude-platform.md @@ -4,3 +4,25 @@ You have a `send_message` tool that sends a message immediately while you are st Use it to acknowledge a request before starting longer work. When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to. + +## Image attachments + +When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose: + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "스크린샷을 첨부했습니다.", + "attachments": [ + { + "path": "/absolute/path/screenshot.png", + "name": "screenshot.png", + "mime": "image/png" + } + ] + } +} +``` + +Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index 612cef2..d2e07ca 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -28,9 +28,36 @@ Your output is sent directly to the Discord group. - Do not use generic recurring task registration from Codex - If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it +## Image attachments + +When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`. + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "이미지를 생성했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/absolute/path/image.png", + "name": "image.png", + "mime": "image/png" + } + ] + } +} +``` + +- `attachments.path` must be an absolute local path; URLs and relative paths are ignored +- Do not repeat the same file path in the visible text +- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. +- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file + ## CI 감시 (watch_ci) GitHub Actions run 감시는 structured 필드를 우선 사용: + - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling) - structured 필드 없이 generic 등록 시 매 tick LLM 실행됨 diff --git a/prompts/owner-common-platform.md b/prompts/owner-common-platform.md index b217fc8..67204b8 100644 --- a/prompts/owner-common-platform.md +++ b/prompts/owner-common-platform.md @@ -20,9 +20,36 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context. +## Image attachments + +For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths: + +```json +{ + "ejclaw": { + "visibility": "public", + "text": "스크린샷을 첨부했습니다.", + "verdict": "done", + "attachments": [ + { + "path": "/absolute/path/screenshot.png", + "name": "screenshot.png", + "mime": "image/png" + } + ] + } +} +``` + +- Use absolute local paths only +- Do not duplicate the same path in the visible text +- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. +- The channel harness validates and uploads attachments; plain prose paths are not reliable + ## CI monitoring (watch_ci) GitHub Actions run monitoring uses structured fields first: + - ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID - This combination → host-driven fast path (no LLM token cost, 15s polling) - Without structured fields → generic path, each tick runs LLM diff --git a/runners/agent-runner/src/output-protocol.ts b/runners/agent-runner/src/output-protocol.ts index 8d38f0d..f5ded31 100644 --- a/runners/agent-runner/src/output-protocol.ts +++ b/runners/agent-runner/src/output-protocol.ts @@ -3,7 +3,7 @@ import path from 'path'; import { extractImageTagPaths, - normalizePublicTextOutput, + normalizeEjclawStructuredOutput, type RunnerStructuredOutput, writeProtocolOutput, } from 'ejclaw-runners-shared'; @@ -151,7 +151,7 @@ export function normalizeStructuredOutput(result: string | null): { result: string | null; output?: RunnerOutput['output']; } { - return normalizePublicTextOutput(result); + return normalizeEjclawStructuredOutput(result); } export function extractAssistantText(message: unknown): string | null { diff --git a/runners/shared/src/agent-protocol.ts b/runners/shared/src/agent-protocol.ts index 89aa8f0..20a1494 100644 --- a/runners/shared/src/agent-protocol.ts +++ b/runners/shared/src/agent-protocol.ts @@ -1,7 +1,8 @@ export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; -export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; +export const IMAGE_TAG_RE = + /\[Image:\s*(?:(?:[^\]\n]*?)\s*→\s*)?(\/[^\]\n]+)\]/g; export const IPC_POLL_MS = 500; export const IPC_INPUT_SUBDIR = 'input'; @@ -21,11 +22,18 @@ export type RunnerOutputVerdict = export type RunnerOutputVisibility = 'public' | 'silent'; +export interface RunnerOutputAttachment { + path: string; + name?: string; + mime?: string; +} + export type RunnerStructuredOutput = | { visibility: 'public'; text: string; verdict?: Exclude; + attachments?: RunnerOutputAttachment[]; } | { visibility: 'silent'; @@ -89,6 +97,33 @@ function isVisibleVerdict( ); } +function normalizeAttachments(value: unknown): RunnerOutputAttachment[] { + if (!Array.isArray(value)) return []; + + const attachments: RunnerOutputAttachment[] = []; + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const candidate = item as { + path?: unknown; + name?: unknown; + mime?: unknown; + }; + if (typeof candidate.path !== 'string' || candidate.path.length === 0) { + continue; + } + attachments.push({ + path: candidate.path, + ...(typeof candidate.name === 'string' && candidate.name.length > 0 + ? { name: candidate.name } + : {}), + ...(typeof candidate.mime === 'string' && candidate.mime.length > 0 + ? { mime: candidate.mime } + : {}), + }); + } + return attachments; +} + export function normalizeEjclawStructuredOutput( result: string | null, ): NormalizedRunnerOutput { @@ -99,7 +134,12 @@ export function normalizeEjclawStructuredOutput( const trimmed = result.trim(); try { const parsed = JSON.parse(trimmed) as { - ejclaw?: { visibility?: unknown; text?: unknown; verdict?: unknown }; + ejclaw?: { + visibility?: unknown; + text?: unknown; + verdict?: unknown; + attachments?: unknown; + }; }; const envelope = parsed?.ejclaw; if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) { @@ -128,6 +168,7 @@ export function normalizeEjclawStructuredOutput( ) { return normalizePublicTextOutput(result); } + const attachments = normalizeAttachments(envelope.attachments); return { result: envelope.text, output: { @@ -136,6 +177,7 @@ export function normalizeEjclawStructuredOutput( verdict: isVisibleVerdict(envelope.verdict) ? envelope.verdict : undefined, + ...(attachments.length > 0 ? { attachments } : {}), }, }; } diff --git a/runners/shared/src/index.ts b/runners/shared/src/index.ts index eb7cd92..8e432d1 100644 --- a/runners/shared/src/index.ts +++ b/runners/shared/src/index.ts @@ -15,6 +15,7 @@ export { writeProtocolOutput, type NormalizedRunnerOutput, type RunnerOutputPhase, + type RunnerOutputAttachment, type RunnerOutputVerdict, type RunnerOutputVisibility, type RunnerStructuredOutput, diff --git a/runners/shared/test/agent-protocol.test.ts b/runners/shared/test/agent-protocol.test.ts index 37adb62..74e631f 100644 --- a/runners/shared/test/agent-protocol.test.ts +++ b/runners/shared/test/agent-protocol.test.ts @@ -13,6 +13,12 @@ describe('shared agent protocol helpers', () => { cleanText: 'hello', imagePaths: ['/tmp/a.png'], }); + expect( + extractImageTagPaths('hello [Image: screenshot.png → /tmp/a.png]'), + ).toEqual({ + cleanText: 'hello', + imagePaths: ['/tmp/a.png'], + }); expect(extractImageTagPaths('[Image: /tmp/b.png] second')).toEqual({ cleanText: 'second', imagePaths: ['/tmp/b.png'], @@ -45,6 +51,41 @@ describe('shared agent protocol helpers', () => { }); }); + it('parses public ejclaw attachments', () => { + expect( + normalizeEjclawStructuredOutput( + JSON.stringify({ + ejclaw: { + visibility: 'public', + text: '이미지를 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }, + }), + ), + ).toEqual({ + result: '이미지를 생성했습니다.', + output: { + visibility: 'public', + text: '이미지를 생성했습니다.', + verdict: 'done', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }, + }); + }); + it('falls back to visible raw text on invalid public verdicts', () => { const raw = JSON.stringify({ ejclaw: { diff --git a/src/agent-output.ts b/src/agent-output.ts index 1d7e8d9..d8fb478 100644 --- a/src/agent-output.ts +++ b/src/agent-output.ts @@ -34,6 +34,16 @@ export function getAgentOutputText(output: { return stringifyLegacyAgentResult(output.result); } +export function getAgentOutputAttachments(output: { + output?: StructuredAgentOutput; +}): NonNullable< + Extract['attachments'] +> { + const structured = getStructuredAgentOutput(output); + if (structured?.visibility !== 'public') return []; + return structured.attachments ?? []; +} + export function hasAgentOutputPayload(output: { output?: StructuredAgentOutput; result?: string | object | null; diff --git a/src/agent-protocol.ts b/src/agent-protocol.ts index 53db438..17daa32 100644 --- a/src/agent-protocol.ts +++ b/src/agent-protocol.ts @@ -10,6 +10,7 @@ export { OUTPUT_START_MARKER, writeProtocolOutput, type NormalizedRunnerOutput, + type RunnerOutputAttachment, type RunnerOutputPhase, type RunnerOutputVerdict, type RunnerOutputVisibility, diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 1171424..277098f 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -1,3 +1,7 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // --- Mocks --- @@ -127,6 +131,18 @@ import { logger } from '../logger.js'; // --- Test helpers --- +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +function createTempPng(name = 'image.png'): { dir: string; filePath: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-discord-image-')); + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, ONE_PIXEL_PNG); + return { dir, filePath }; +} + function createTestOpts( overrides?: Partial, ): DiscordChannelOpts { @@ -839,6 +855,59 @@ describe('DiscordChannel', () => { }); }); + it('sends structured attachments as Discord files', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('structured.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + '이미지를 생성했습니다.', + { + attachments: [ + { path: filePath, name: 'result.png', mime: 'image/png' }, + ], + }, + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '이미지를 생성했습니다.', + files: [{ attachment: filePath, name: 'result.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('uses legacy image tags as Discord attachment fallback', async () => { + const opts = createTestOpts(); + const channel = new DiscordChannel('test-token', opts); + await channel.connect(); + const { dir, filePath } = createTempPng('screenshot.png'); + const mockChannel = { + send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }), + sendTyping: vi.fn(), + }; + currentClient().channels.fetch.mockResolvedValue(mockChannel); + + await channel.sendMessage( + 'dc:1234567890123456', + `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`, + ); + + expect(mockChannel.send).toHaveBeenCalledWith({ + content: '스크린샷입니다.', + files: [{ attachment: filePath, name: 'screenshot.png' }], + flags: 1 << 2, + }); + fs.rmSync(dir, { recursive: true, force: true }); + }); + it('logs channel name and Discord message ids after sending', async () => { const opts = createTestOpts(); const channel = new DiscordChannel('test-token', opts); diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 75bcaad..84a64f9 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -19,8 +19,11 @@ import { } from '../config.js'; import { getEnv } from '../env.js'; import { logger } from '../logger.js'; +import { extractImageTagPaths } from '../agent-protocol.js'; +import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; +import type { OutboundAttachment, SendMessageOptions } from '../types.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); @@ -30,6 +33,45 @@ const DISCORD_ARBITER_CHANNEL = 'discord-arbiter'; const DISCORD_OWNER_TOKEN_KEY = 'DISCORD_OWNER_BOT_TOKEN'; const DISCORD_REVIEWER_TOKEN_KEY = 'DISCORD_REVIEWER_BOT_TOKEN'; const DISCORD_ARBITER_TOKEN_KEY = 'DISCORD_ARBITER_BOT_TOKEN'; +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; +const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; + +function extractMarkdownImageAttachments(text: string): { + cleanText: string; + attachments: OutboundAttachment[]; +} { + const attachments: OutboundAttachment[] = []; + const seen = new Set(); + + const cleanText = text.replace(MD_LINK_RE, (_full, rawPath: string) => { + const trimmed = rawPath.trim(); + if (IMAGE_EXTS.test(trimmed)) { + if (!seen.has(trimmed)) { + attachments.push({ + path: trimmed, + name: path.basename(trimmed), + }); + seen.add(trimmed); + } + return ''; + } + + const basename = path.basename(trimmed.replace(/#.*$/, '')); + const lineMatch = trimmed.match(/#L(\d+)/); + return lineMatch ? `\`${basename}:${lineMatch[1]}\`` : `\`${basename}\``; + }); + + return { cleanText, attachments }; +} + +function imageTagPathsToAttachments(paths: string[]): OutboundAttachment[] { + return paths + .filter((filePath) => IMAGE_EXTS.test(filePath)) + .map((filePath) => ({ + path: filePath, + name: path.basename(filePath), + })); +} /** * Download a Discord attachment to local disk. @@ -403,7 +445,11 @@ export class DiscordChannel implements Channel { }); } - async sendMessage(jid: string, text: string): Promise { + async sendMessage( + jid: string, + text: string, + options: SendMessageOptions = {}, + ): Promise { if (!this.client) { logger.warn('Discord client not initialized'); return; @@ -420,37 +466,43 @@ export class DiscordChannel implements Channel { const textChannel = channel as TextChannel; - // Extract image attachments from markdown links with image extensions - // e.g. [name.png](/absolute/path/name.png) - const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|bmp)$/i; - const MD_LINK_RE = /\[[^\]]*\]\((\/[^)]+)\)/g; - const imageFiles: string[] = []; - const seen = new Set(); - let match; + const structuredAttachments = options.attachments ?? []; + const hasStructuredAttachments = structuredAttachments.length > 0; + const markdownExtracted = extractMarkdownImageAttachments(text); + const imageTagExtracted = extractImageTagPaths( + markdownExtracted.cleanText, + ); + const legacyImageTagAttachments = imageTagPathsToAttachments( + imageTagExtracted.imagePaths, + ); + const outboundAttachments = hasStructuredAttachments + ? structuredAttachments + : [...markdownExtracted.attachments, ...legacyImageTagAttachments]; + const attachmentSource = hasStructuredAttachments + ? 'structured' + : markdownExtracted.attachments.length > 0 + ? 'md-link' + : legacyImageTagAttachments.length > 0 + ? 'image-tag' + : 'none'; + const validation = validateOutboundAttachments(outboundAttachments, { + baseDirs: options.attachmentBaseDirs, + }); + const files = validation.files; - while ((match = MD_LINK_RE.exec(text)) !== null) { - const imgPath = match[1].trim(); - if ( - !seen.has(imgPath) && - IMAGE_EXTS.test(imgPath) && - fs.existsSync(imgPath) - ) { - imageFiles.push(imgPath); - seen.add(imgPath); - } + if (validation.rejected.length > 0) { + logger.warn( + { + jid, + channelName: this.name, + attachmentSource, + rejected: validation.rejected, + }, + 'Rejected outbound Discord attachments', + ); } - let cleaned = text - .replace(MD_LINK_RE, (full, p, _offset, _str, groups) => { - const trimmed = p.trim(); - // Image links: remove entirely (attached as files) - if (IMAGE_EXTS.test(trimmed) && seen.has(trimmed)) return ''; - // Non-image local path links: convert to readable filename - const basename = path.basename(trimmed.replace(/#.*$/, '')); - const lineMatch = trimmed.match(/#L(\d+)/); - return lineMatch - ? `\`${basename}:${lineMatch[1]}\`` - : `\`${basename}\``; - }) + + let cleaned = imageTagExtracted.cleanText .replace(/^[ \t]*[•\-\*][ \t]*$/gm, '') // remove empty bullet lines .replace(/\n{3,}/g, '\n\n') // collapse excessive blank lines .trim(); @@ -467,10 +519,6 @@ export class DiscordChannel implements Channel { // Discord has a 2000 character limit per message and 10 attachments per message const MAX_LENGTH = 2000; const MAX_ATTACHMENTS = 10; - const files = imageFiles.map((f) => ({ - attachment: f, - name: path.basename(f), - })); const sentMessageIds: string[] = []; let chunkCount = 0; @@ -544,6 +592,7 @@ export class DiscordChannel implements Channel { deliveryMode: 'send', chunkCount, attachmentCount: files.length, + attachmentSource, messageId: sentMessageIds[0] ?? null, messageIds: sentMessageIds, botUserId: this.client.user?.id ?? null, diff --git a/src/db.test.ts b/src/db.test.ts index 0a15fc6..8a5a3f0 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -9106,6 +9106,38 @@ describe('work items', () => { ).toBeUndefined(); }); + it('stores produced work item attachments for delivery retries', () => { + const item = createProducedWorkItem({ + group_folder: 'discord_test', + chat_jid: 'dc:attachments', + agent_type: 'claude-code', + delivery_role: 'owner', + start_seq: 1, + end_seq: 2, + result_payload: 'image ready', + attachments: [ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ], + }); + + const stored = getOpenWorkItem( + 'dc:attachments', + 'claude-code', + item.service_id, + ); + expect(stored?.attachments).toEqual([ + { + path: '/tmp/image.png', + name: 'image.png', + mime: 'image/png', + }, + ]); + }); + it('finds pending delivery retries even when they were created by a fallback agent type', () => { const fallbackItem = createProducedWorkItem({ group_folder: 'discord_test', diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 0ded6e5..4cae88e 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -39,6 +39,7 @@ export function applyBaseSchema(database: Database): void { start_seq INTEGER, end_seq INTEGER, result_payload TEXT NOT NULL, + attachment_payload TEXT, delivery_attempts INTEGER NOT NULL DEFAULT 0, delivery_message_id TEXT, last_error TEXT, diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index 343fe7a..b8d3c3c 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -38,6 +38,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 11, name: 'owner_failure_count' }, { version: 12, name: 'paired_verdict_and_step_telemetry' }, { version: 13, name: 'message_source_kind' }, + { version: 14, name: 'work_item_attachments' }, ]; } diff --git a/src/db/migrations/014_work-item-attachments.ts b/src/db/migrations/014_work-item-attachments.ts new file mode 100644 index 0000000..d5a3522 --- /dev/null +++ b/src/db/migrations/014_work-item-attachments.ts @@ -0,0 +1,17 @@ +import type { Database } from 'bun:sqlite'; + +import { tableHasColumn } from './helpers.js'; +import type { SchemaMigrationDefinition } from './types.js'; + +export const WORK_ITEM_ATTACHMENTS_MIGRATION: SchemaMigrationDefinition = { + version: 14, + name: 'work_item_attachments', + apply(database: Database) { + if (!tableHasColumn(database, 'work_items', 'attachment_payload')) { + database.exec(` + ALTER TABLE work_items + ADD COLUMN attachment_payload TEXT + `); + } + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index af0f435..ddd4bb2 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -13,6 +13,7 @@ import { PAIRED_TURN_PROVENANCE_UPGRADE_MIGRATION } from './010_paired-turn-prov import { OWNER_FAILURE_COUNT_MIGRATION } from './011_owner-failure-count.js'; import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdict-and-step-telemetry.js'; import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; +import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js'; import type { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -34,6 +35,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ OWNER_FAILURE_COUNT_MIGRATION, PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION, MESSAGE_SOURCE_KIND_MIGRATION, + WORK_ITEM_ATTACHMENTS_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/work-items.ts b/src/db/work-items.ts index f5af722..8a9e51c 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -6,7 +6,7 @@ import { inferRoleFromServiceShadow, resolveRoleServiceShadow, } from '../role-service-shadow.js'; -import { AgentType, PairedRoomRole } from '../types.js'; +import { AgentType, OutboundAttachment, PairedRoomRole } from '../types.js'; export interface WorkItem { id: number; @@ -19,6 +19,7 @@ export interface WorkItem { start_seq: number | null; end_seq: number | null; result_payload: string; + attachments?: OutboundAttachment[]; delivery_attempts: number; delivery_message_id: string | null; last_error: string | null; @@ -29,10 +30,11 @@ export interface WorkItem { interface StoredWorkItemRow extends Omit< WorkItem, - 'agent_type' | 'service_id' + 'agent_type' | 'service_id' | 'attachments' > { agent_type: string; service_id?: string | null; + attachment_payload?: string | null; } export interface CreateProducedWorkItemInput { @@ -44,6 +46,7 @@ export interface CreateProducedWorkItemInput { start_seq: number | null; end_seq: number | null; result_payload: string; + attachments?: OutboundAttachment[]; } function normalizeStoredAgentType( @@ -96,9 +99,48 @@ function hydrateWorkItemRow(row: StoredWorkItemRow): WorkItem { ...row, agent_type: agentType, service_id: readStoredWorkItemServiceId(row), + attachments: parseAttachmentPayload(row.attachment_payload), }; } +function parseAttachmentPayload( + payload: string | null | undefined, +): OutboundAttachment[] { + if (!payload) return []; + try { + const parsed = JSON.parse(payload) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed + .filter( + (item): item is OutboundAttachment => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + typeof (item as { path?: unknown }).path === 'string', + ) + .map((item) => ({ + path: item.path, + ...(typeof item.name === 'string' ? { name: item.name } : {}), + ...(typeof item.mime === 'string' ? { mime: item.mime } : {}), + })); + } catch { + return []; + } +} + +function serializeAttachmentPayload( + attachments: OutboundAttachment[] | undefined, +): string | null { + if (!attachments?.length) return null; + return JSON.stringify( + attachments.map((attachment) => ({ + path: attachment.path, + ...(attachment.name ? { name: attachment.name } : {}), + ...(attachment.mime ? { mime: attachment.mime } : {}), + })), + ); +} + function resolvePreferredWorkItemRole( serviceId: string | null | undefined, ): PairedRoomRole | null { @@ -220,10 +262,11 @@ export function createProducedWorkItemInDatabase( start_seq, end_seq, result_payload, + attachment_payload, delivery_attempts, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, 0, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, 'produced', ?, ?, ?, ?, 0, ?, ?)`, ) .run( input.group_folder, @@ -234,6 +277,7 @@ export function createProducedWorkItemInDatabase( input.start_seq, input.end_seq, input.result_payload, + serializeAttachmentPayload(input.attachments), now, now, ); diff --git a/src/message-runtime-delivery.ts b/src/message-runtime-delivery.ts index d64ac34..4d01962 100644 --- a/src/message-runtime-delivery.ts +++ b/src/message-runtime-delivery.ts @@ -28,11 +28,14 @@ export async function deliverOpenWorkItem(args: { channel: Channel; item: WorkItem; log: RuntimeDeliveryLog; + attachmentBaseDirs?: string[]; replaceMessageId?: string | null; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; openContinuation: (chatJid: string) => void; }): Promise { const replaceMessageId = args.replaceMessageId ?? null; + const attachments = args.item.attachments ?? []; + const hasAttachments = attachments.length > 0; const isDuplicate = args.isDuplicateOfLastBotFinal( args.item.chat_jid, @@ -52,7 +55,7 @@ export async function deliverOpenWorkItem(args: { } try { - if (replaceMessageId && args.channel.editMessage) { + if (replaceMessageId && args.channel.editMessage && !hasAttachments) { args.log.info( buildDeliveryLogContext(args.channel, args.item, { deliveryAttempts: args.item.delivery_attempts + 1, @@ -93,19 +96,32 @@ export async function deliverOpenWorkItem(args: { try { args.log.info( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', }), 'Attempting to deliver produced work item as a new message', ); - await args.channel.sendMessage( - args.item.chat_jid, - args.item.result_payload, - ); + if (hasAttachments) { + await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + { + attachmentBaseDirs: args.attachmentBaseDirs, + attachments, + }, + ); + } else { + await args.channel.sendMessage( + args.item.chat_jid, + args.item.result_payload, + ); + } markWorkItemDelivered(args.item.id); args.openContinuation(args.item.chat_jid); args.log.info( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', }), @@ -117,6 +133,7 @@ export async function deliverOpenWorkItem(args: { markWorkItemDeliveryRetry(args.item.id, errorMessage); args.log.warn( buildDeliveryLogContext(args.channel, args.item, { + attachmentCount: attachments.length, deliveryAttempts: args.item.delivery_attempts + 1, deliveryMode: 'send', err, @@ -135,6 +152,7 @@ export async function processOpenWorkItemDelivery(args: { channel: Channel; roleToChannel: Record<'owner' | 'reviewer' | 'arbiter', Channel | null>; log: RuntimeDeliveryLog; + attachmentBaseDirs?: string[]; isPairedRoom: boolean; getMissingRoleChannelMessage: (role: 'reviewer' | 'arbiter') => string; isDuplicateOfLastBotFinal: (chatJid: string, text: string) => boolean; @@ -186,6 +204,7 @@ export async function processOpenWorkItemDelivery(args: { channel: deliveryChannel, item: openWorkItem, log: args.log, + attachmentBaseDirs: args.attachmentBaseDirs, isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal, openContinuation: args.openContinuation, }); diff --git a/src/message-runtime-turns.ts b/src/message-runtime-turns.ts index 5e2bce3..237de10 100644 --- a/src/message-runtime-turns.ts +++ b/src/message-runtime-turns.ts @@ -11,9 +11,10 @@ import { resolvePairedTurnRunOwnership } from './paired-turn-run-ownership.js'; import { normalizeMessageForDedupe } from './router.js'; import type { ExecuteTurnFn } from './message-runtime-types.js'; import type { + AgentType, Channel, NewMessage, - AgentType, + OutboundAttachment, PairedRoomRole, RegisteredGroup, } from './types.js'; @@ -100,6 +101,7 @@ interface CreateExecuteTurnDeps { clearSession: (groupFolder: string, opts?: { allRoles?: boolean }) => void; deliverFinalText: (args: { text: string; + attachments?: OutboundAttachment[]; chatJid: string; runId: string; channel: Channel; @@ -202,6 +204,9 @@ export function createExecuteTurn(deps: CreateExecuteTurnDeps): ExecuteTurnFn { try { return await deps.deliverFinalText({ text, + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), chatJid, runId, channel, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index bea78ab..b2c197a 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -210,6 +210,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { clearSession: deps.clearSession, deliverFinalText: async ({ text, + attachments, chatJid, runId, channel, @@ -248,11 +249,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { start_seq: startSeq, end_seq: endSeq, result_payload: text, + attachments, }); return deliverOpenWorkItem({ channel, item: workItem, log: logger, + attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, replaceMessageId, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, openContinuation: (targetChatJid) => @@ -455,6 +458,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel, roleToChannel, log, + attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, isPairedRoom: hasReviewerLease(chatJid), getMissingRoleChannelMessage, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index 7137190..acc4912 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -317,6 +317,59 @@ describe('MessageTurnController outbound audit logging', () => { ); }); + it('passes structured final attachments to final delivery', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + const attachments = [ + { + path: '/tmp/e2e-screenshot.png', + name: 'e2e-screenshot.png', + mime: 'image/png', + }, + ]; + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-review-attachments', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'reviewer', + deliveryServiceId: 'codex-review', + pairedTurnIdentity: makeTurnIdentity(), + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'final', + result: '스크린샷을 첨부했습니다.', + output: { + visibility: 'public', + text: '스크린샷을 첨부했습니다.', + attachments, + }, + } as any); + await controller.finish('success'); + + expect(deliverFinalText).toHaveBeenCalledWith('스크린샷을 첨부했습니다.', { + replaceMessageId: null, + attachments, + }); + expect(getAuditEntries()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + auditEvent: 'final-delivery-attempt', + attachmentCount: 1, + }), + ]), + ); + }); + it('replaces the tracked progress message when an owner final arrives', async () => { const channel = { ...makeChannel(), diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 31424a4..ba0b6fd 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -1,5 +1,8 @@ import { type AgentOutput } from './agent-runner.js'; -import { getAgentOutputText } from './agent-output.js'; +import { + getAgentOutputAttachments, + getAgentOutputText, +} from './agent-output.js'; import { createScopedLogger, logger } from './logger.js'; import { formatOutbound } from './router.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; @@ -11,6 +14,7 @@ import { toVisiblePhase, type AgentOutputPhase, type Channel, + type OutboundAttachment, type PairedRoomRole, type RegisteredGroup, type VisiblePhase, @@ -35,7 +39,10 @@ interface MessageTurnControllerOptions { requestClose: (reason: string) => void; deliverFinalText: ( text: string, - options?: { replaceMessageId?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + replaceMessageId?: string | null; + }, ) => Promise; canDeliverFinalText?: () => boolean; allowProgressReplayWithoutFinal?: boolean; @@ -160,6 +167,7 @@ export class MessageTurnController { const raw = getAgentOutputText(result); const text = raw ? formatOutbound(raw) : null; + const attachments = getAgentOutputAttachments(result); if (raw) { this.log.info( @@ -297,6 +305,7 @@ export class MessageTurnController { // then discard the pending buffer so it never shows up. if (text) { await this.publishTerminalText(text, { + attachments, flushPendingText: text, }); } else if (raw) { @@ -648,14 +657,22 @@ export class MessageTurnController { private async publishTerminalText( text: string, - options?: { flushPendingText?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + flushPendingText?: string | null; + }, ): Promise { if (options?.flushPendingText) { await this.flushPendingProgress(options.flushPendingText); } const replaceMessageId = this.consumeProgressForFinalDelivery(); - await this.deliverFinalText(text, { replaceMessageId }); + await this.deliverFinalText(text, { + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), + replaceMessageId, + }); } private consumeProgressForFinalDelivery(): string | null { @@ -675,7 +692,10 @@ export class MessageTurnController { private async deliverFinalText( text: string, - options?: { replaceMessageId?: string | null }, + options?: { + attachments?: OutboundAttachment[]; + replaceMessageId?: string | null; + }, ): Promise { await this.activateTyping('turn:deliver-final'); this.visiblePhase = toVisiblePhase('final'); @@ -697,14 +717,19 @@ export class MessageTurnController { return; } this.logOutboundAudit('final-delivery-attempt', { + attachmentCount: options?.attachments?.length ?? 0, messageId: replaceMessageId, textLength: text.length, deliveryMode: replaceMessageId ? 'edit' : 'send', }); const delivered = await this.options.deliverFinalText(text, { + ...(options?.attachments?.length + ? { attachments: options.attachments } + : {}), replaceMessageId, }); this.logOutboundAudit('final-delivery-result', { + attachmentCount: options?.attachments?.length ?? 0, messageId: replaceMessageId, textLength: text.length, deliveryMode: replaceMessageId ? 'edit' : 'send', diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts new file mode 100644 index 0000000..85c0fa3 --- /dev/null +++ b/src/outbound-attachments.test.ts @@ -0,0 +1,149 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { validateOutboundAttachments } from './outbound-attachments.js'; + +const ONE_PIXEL_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const cleanupDirs: string[] = []; + +function makeTempDir(baseDir: string, prefix: string): string { + const dir = fs.mkdtempSync(path.join(baseDir, prefix)); + cleanupDirs.push(dir); + return dir; +} + +function writeFile( + dir: string, + name: string, + content: Buffer | string, +): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content); + return filePath; +} + +afterEach(() => { + for (const dir of cleanupDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('validateOutboundAttachments', () => { + it('accepts real image files under default attachment directories', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const imagePath = writeFile(dir, 'screenshot.png', ONE_PIXEL_PNG); + + const result = validateOutboundAttachments([ + { + path: imagePath, + name: '../unsafe-name.png', + mime: 'image/png', + }, + ]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'unsafe-name.png', + }, + ]); + }); + + it('requires workspace paths to be explicitly allowlisted', () => { + const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); + const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); + + expect(validateOutboundAttachments([{ path: imagePath }])).toEqual({ + files: [], + rejected: [{ path: imagePath, reason: 'outside-allowed-dirs' }], + }); + + const allowed = validateOutboundAttachments([{ path: imagePath }], { + baseDirs: [dir], + }); + + expect(allowed.rejected).toEqual([]); + expect(allowed.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'workspace-shot.png', + }, + ]); + }); + + it('rejects symlink attempts that escape the allowed directory', () => { + const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); + const targetPath = writeFile( + workspaceDir, + 'secret-shot.png', + ONE_PIXEL_PNG, + ); + const tmpDir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const linkPath = path.join(tmpDir, 'linked-shot.png'); + fs.symlinkSync(targetPath, linkPath); + + const result = validateOutboundAttachments([{ path: linkPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: linkPath, reason: 'outside-allowed-dirs' }, + ]); + }); + + it('rejects SVG attachments before inspecting file content', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const svgPath = writeFile(dir, 'vector.svg', ''); + + const result = validateOutboundAttachments([{ path: svgPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: svgPath, reason: 'unsupported-extension' }, + ]); + }); + + it('rejects files whose extension and image signature do not match policy', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const fakePng = writeFile(dir, 'fake.png', 'not an image'); + const realPng = writeFile(dir, 'real.png', ONE_PIXEL_PNG); + + expect(validateOutboundAttachments([{ path: fakePng }])).toEqual({ + files: [], + rejected: [{ path: fakePng, reason: 'invalid-image-signature' }], + }); + expect( + validateOutboundAttachments([{ path: realPng, mime: 'image/jpeg' }]), + ).toEqual({ + files: [], + rejected: [{ path: realPng, reason: 'mime-mismatch' }], + }); + }); + + it('rejects non-files and files over the size cap', () => { + const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); + const nestedDir = path.join(dir, 'nested.png'); + fs.mkdirSync(nestedDir); + const largePng = writeFile( + dir, + 'large.png', + Buffer.concat([ONE_PIXEL_PNG, Buffer.alloc(8 * 1024 * 1024)]), + ); + + expect(validateOutboundAttachments([{ path: nestedDir }])).toEqual({ + files: [], + rejected: [{ path: nestedDir, reason: 'not-file' }], + }); + expect(validateOutboundAttachments([{ path: largePng }])).toEqual({ + files: [], + rejected: [{ path: largePng, reason: 'too-large' }], + }); + }); +}); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts new file mode 100644 index 0000000..fc7feb7 --- /dev/null +++ b/src/outbound-attachments.ts @@ -0,0 +1,174 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import type { OutboundAttachment } from './types.js'; + +export interface ValidatedOutboundAttachment { + attachment: string; + name: string; +} + +export interface ValidateOutboundAttachmentsResult { + files: ValidatedOutboundAttachment[]; + rejected: Array<{ path: string; reason: string }>; +} + +const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; +const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; + +function unique(values: Array): string[] { + return [ + ...new Set(values.filter((value): value is string => Boolean(value))), + ]; +} + +function resolveExistingDir(dir: string): string | null { + try { + if (!fs.existsSync(dir)) return null; + return fs.realpathSync(dir); + } catch { + return null; + } +} + +export function getDefaultAttachmentBaseDirs(): string[] { + const home = process.env.HOME; + const codexHome = + process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); + // Keep defaults narrow. Runtime-specific workspaces must be passed via + // attachmentBaseDirs so one room cannot attach another room's files by path. + return unique([ + '/tmp', + path.join(DATA_DIR, 'attachments'), + codexHome ? path.join(codexHome, 'generated_images') : null, + ]) + .map(resolveExistingDir) + .filter((dir): dir is string => Boolean(dir)); +} + +function isWithinBaseDir(realPath: string, baseDir: string): boolean { + const relative = path.relative(baseDir, realPath); + return ( + relative === '' || + (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean { + return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir)); +} + +function detectImageMime(filePath: string): string | null { + const handle = fs.openSync(filePath, 'r'); + try { + const buffer = Buffer.alloc(512); + const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0); + const header = buffer.subarray(0, bytesRead); + if ( + header + .subarray(0, 8) + .equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return 'image/png'; + } + if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff) { + return 'image/jpeg'; + } + if ( + header.subarray(0, 6).toString('ascii') === 'GIF87a' || + header.subarray(0, 6).toString('ascii') === 'GIF89a' + ) { + return 'image/gif'; + } + if ( + header.subarray(0, 4).toString('ascii') === 'RIFF' && + header.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + if (header[0] === 0x42 && header[1] === 0x4d) { + return 'image/bmp'; + } + return null; + } finally { + fs.closeSync(handle); + } +} + +function normalizeAttachmentName( + attachment: OutboundAttachment, + realPath: string, +): string { + const candidate = attachment.name + ? path.basename(attachment.name) + : path.basename(realPath); + return candidate || 'attachment'; +} + +export function validateOutboundAttachments( + attachments: OutboundAttachment[] | undefined, + options: { baseDirs?: string[] } = {}, +): ValidateOutboundAttachmentsResult { + const baseDirs = unique([ + ...getDefaultAttachmentBaseDirs(), + ...(options.baseDirs ?? []).map(resolveExistingDir), + ]).filter((dir): dir is string => Boolean(dir)); + const files: ValidatedOutboundAttachment[] = []; + const rejected: Array<{ path: string; reason: string }> = []; + const seen = new Set(); + + for (const attachment of attachments ?? []) { + const requestedPath = attachment.path; + try { + if (!path.isAbsolute(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'not-absolute' }); + continue; + } + if (!IMAGE_EXTS.test(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'unsupported-extension' }); + continue; + } + if (!fs.existsSync(requestedPath)) { + rejected.push({ path: requestedPath, reason: 'not-found' }); + continue; + } + const realPath = fs.realpathSync(requestedPath); + if (seen.has(realPath)) continue; + const stat = fs.statSync(realPath); + if (!stat.isFile()) { + rejected.push({ path: requestedPath, reason: 'not-file' }); + continue; + } + if (stat.size > MAX_ATTACHMENT_BYTES) { + rejected.push({ path: requestedPath, reason: 'too-large' }); + continue; + } + if (!matchesAllowedBaseDir(realPath, baseDirs)) { + rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); + continue; + } + const detectedMime = detectImageMime(realPath); + if (!detectedMime) { + rejected.push({ + path: requestedPath, + reason: 'invalid-image-signature', + }); + continue; + } + if (attachment.mime && attachment.mime !== detectedMime) { + rejected.push({ path: requestedPath, reason: 'mime-mismatch' }); + continue; + } + files.push({ + attachment: realPath, + name: normalizeAttachmentName(attachment, realPath), + }); + seen.add(realPath); + } catch { + rejected.push({ path: requestedPath, reason: 'validation-error' }); + } + } + + return { files, rejected }; +} diff --git a/src/types.ts b/src/types.ts index 5c00ed3..07f72f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,22 @@ export type VisiblePhase = 'silent' | 'progress' | 'final'; export type AgentVisibility = 'public' | 'silent'; +export interface OutboundAttachment { + path: string; + name?: string; + mime?: string; +} + +export interface SendMessageOptions { + attachments?: OutboundAttachment[]; + /** + * Extra realpath roots that are valid for this delivery attempt. Runtime + * callers can pass the active project/workspace directory without widening + * the global Discord attachment allowlist. + */ + attachmentBaseDirs?: string[]; +} + export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = @@ -126,6 +142,7 @@ export type StructuredAgentOutput = | { visibility: 'public'; text: string; + attachments?: OutboundAttachment[]; } | { visibility: 'silent'; @@ -234,7 +251,11 @@ export interface ChannelOutboundAuditMeta { export interface Channel { name: string; connect(): Promise; - sendMessage(jid: string, text: string): Promise; + sendMessage( + jid: string, + text: string, + options?: SendMessageOptions, + ): Promise; isConnected(): boolean; ownsJid(jid: string): boolean; // Optional: whether a stored inbound message was authored by this channel's own bot/user.