/** * Claude Agent SDK wrapper — v2 Long-lived Query Model * * Two modes: * 1. Live Conversation (main chat + admin WhatsApp): * Single long-lived query() per conversation. User messages pushed into async queue. * Agent stays alive, processes messages as they arrive, reports sub-agent completions. * * 2. One-shot Query (customer WhatsApp, scheduler): * Classic request-response: one query() per message. Backward compat. */ import { query, type SDKMessage, type SDKUserMessage, type Options } from '@anthropic-ai/claude-agent-sdk'; import fs from 'fs'; import path from 'path'; import { log } from '../../shared/logger.js'; import { WORKSPACE_DIR } from '../../shared/paths.js'; import type { SavedFile } from '../file-saver.js'; import { getClaudeAccessToken } from '../../worker/claude-auth.js'; import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js'; import { buildAgents } from '../agents/index.js'; import { preWarm, claimWarmup, discardWarmup } from '../cli-warmup.js'; import { mirrorSkillsInto } from './skills.js'; import { routeAttachment, normalizeImageMediaType, approxBase64Bytes, buildSavedFilesNote, INLINE_TEXT_PER_FILE_CHARS, INLINE_TEXT_TOTAL_CHARS, MAX_INLINE_IMAGE_BYTES, } from './attachment-policy.js'; // ── Types ────────────────────────────────────────────────────────────────── import type { RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js'; export type { RecentMessage, AgentAttachment }; // ── Async Queue ──────────────────────────────────────────────────────────── interface AsyncQueue extends AsyncIterable { push(item: T): void; end(): void; } /** Create an async queue that can be used as an AsyncIterable prompt for the SDK */ function createAsyncQueue(): AsyncQueue { const pending: T[] = []; let resolve: ((value: IteratorResult) => void) | null = null; let done = false; return { push(item: T) { if (done) return; if (resolve) { resolve({ value: item, done: false }); resolve = null; } else { pending.push(item); } }, end() { done = true; if (resolve) resolve({ value: undefined as any, done: true }); }, [Symbol.asyncIterator]() { return { next(): Promise> { if (pending.length > 0) { return Promise.resolve({ value: pending.shift()!, done: false }); } if (done) return Promise.resolve({ value: undefined as any, done: true }); return new Promise((r) => { resolve = r; }); }, }; }, }; } // ── Live Conversation Manager ────────────────────────────────────────────── interface LiveConversation { id: string; inputQueue: AsyncQueue; abortController: AbortController; queryHandle: any; onMessage: (type: string, data: any) => void; /** True while the model is actively processing (between message push and result) */ busy: boolean; /** Messages pushed but not yet completed (1 result per message). Used to know when * the session is truly idle — i.e. no queued message — so it's safe to recycle. */ pendingCount: number; } const liveConversations = new Map(); /** Check if a live conversation exists */ export function hasConversation(conversationId: string): boolean { return liveConversations.has(conversationId); } /** End all live conversations (e.g. after re-auth so they restart with fresh token) */ export function endAllConversations(): void { for (const convId of liveConversations.keys()) { log.info(`[conversation] Ending conversation ${convId} (auth changed)`); endConversation(convId); } // The pre-warmed subprocess was initialized with the old OAuth token — drop it. discardWarmup(); } // ── Helpers ───────────────────────────────────────────────────────────────── /** Read a memory file from workspace, returning '(empty)' if missing or empty */ function readMemoryFile(filename: string): string { try { const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim(); return content || '(empty)'; } catch { return '(empty)'; } } /** Read all memory + config files */ function readMemoryFiles() { return { myself: readMemoryFile('MYSELF.md'), myhuman: readMemoryFile('MYHUMAN.md'), memory: readMemoryFile('MEMORY.md'), pulse: readMemoryFile('PULSE.json'), crons: readMemoryFile('CRONS.json'), }; } /** Format recent messages as conversation history text */ function formatConversationHistory(messages: RecentMessage[]): string { if (!messages.length) return ''; return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n'); } // The Agent SDK discovers project-scope skills under `/.claude/skills` // (name+description listed in context, body lazy-loaded via the Skill tool). // Bloby keeps the canonical skills in `workspace/skills/`, so we mirror // each one into `.claude/skills/` as a symlink — same single-source // pattern as the codex harness's `.codex/skills` mirror. The returned names // feed the `skills` option as an explicit allowlist: only Bloby's workspace // skills are enabled, so the human's personal `~/.claude/skills` never leak // into the agent and the option hash stays deterministic for the pre-warmer. const CLAUDE_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.claude', 'skills'); function syncClaudeSkills(): string[] { return mirrorSkillsInto(CLAUDE_SKILLS_ROOT, 'claude'); } /** Load MCP server config from workspace/MCP.json */ function loadMcpServers(): Record | undefined { try { const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json'); const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8')); if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) { return mcpConfig; } else if (Array.isArray(mcpConfig) && mcpConfig.length) { return Object.assign({}, ...mcpConfig); } } catch {} return undefined; } /** Build an SDKUserMessage from text + optional attachments. * Routing is delegated to the shared attachment-policy so all three harnesses * ingest identically. The Anthropic Messages API base64 document source accepts * ONLY application/pdf — handing it a docx/xlsx/csv/markdown/octet-stream 400s * the whole turn — so non-PDF binaries are NOT emitted as provider blocks; they * ride on the saved-files disk pointer instead. Blocks stay MEDIA-FIRST, TEXT-last. */ function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): SDKUserMessage { const content: any[] = []; if (attachments?.length) { // Running budget so the cross-file inline-text total never exceeds the cap. let inlineTextBudget = INLINE_TEXT_TOTAL_CHARS; for (const att of attachments) { // Claude natively renders PDF document blocks (vision over the rendered pages). const route = routeAttachment(att, { canNativeDocument: true }); switch (route) { case 'image': { // Drop the inline copy when it would bloat every stateless resend — the // file is on disk and buildSavedFilesNote points the file tools at it. if (approxBase64Bytes(att.data) > MAX_INLINE_IMAGE_BYTES) break; content.push({ type: 'image', source: { type: 'base64', media_type: normalizeImageMediaType(att.mediaType), data: att.data }, }); break; } case 'native-document': { content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: att.data }, }); break; } case 'inline-text': { if (inlineTextBudget <= 0) break; let decoded = ''; try { decoded = Buffer.from(att.data, 'base64').toString('utf-8'); } catch { break; // undecodable → rely on the saved-files note } const cap = Math.min(INLINE_TEXT_PER_FILE_CHARS, inlineTextBudget); const slice = decoded.slice(0, cap); inlineTextBudget -= slice.length; // text/csv/markdown also 400 as document sources, so inline as a text note. content.push({ type: 'text', text: `--- ${att.name} ---\n${slice}` }); break; } case 'reference-only': default: // Binary we can't inline (docx/xlsx/zip/…) or an unexpected route — no // provider block; the saved-files note below carries the disk pointer. break; } } } let promptText = text || '(attached files)'; if (savedFiles?.length) { const note = buildSavedFilesNote(savedFiles); if (note) promptText += `\n\n${note}`; } content.push({ type: 'text', text: promptText }); return { type: 'user' as const, message: { role: 'user' as const, content }, parent_tool_use_id: null, } as SDKUserMessage; } // ── Live Conversation API ────────────────────────────────────────────────── /** * Build the options for a live conversation's query(). Shared by * `startConversation` and the boot-time pre-warmer so a warmed subprocess * has byte-identical options. */ async function buildConversationOptions( model: string, oauthToken: string, names?: { botName: string; humanName: string }, recentMessages?: RecentMessage[], ): Promise> { const memoryFiles = readMemoryFiles(); const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude'); let systemPrompt = basePrompt; systemPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`; try { const { loadConfig: loadCfg } = await import('../../shared/config.js'); const cfg = loadCfg(); const channels = (cfg as any).channels; if (channels) { systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``; } } catch {} if (recentMessages?.length) { systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`; } const agents = buildAgents(); const mcpServers = loadMcpServers(); const skills = syncClaudeSkills(); return { model, // Reasoning effort. 'high' = deep reasoning while staying more token-efficient // than the CLI's xhigh default on Opus 4.7/4.8 — meaningful given Anthropic's // tighter third-party usage limits. Supported on Opus 4.6+/Sonnet 4.6; silently // ignored by models without effort support. effort: 'high', cwd: WORKSPACE_DIR, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, systemPrompt, mcpServers, agents, skills, agentProgressSummaries: true, // Auto-compaction: the live conversation is a single long-lived query() whose // context grows every turn (messages + tool results + sub-agent transcripts). // Enable it explicitly via inline settings so it does NOT depend on filesystem // settings.json being present — when context fills, the SDK summarizes older // history and continues instead of hitting the hard context wall. settings: { autoCompactEnabled: true }, env: { ...process.env as Record, CLAUDE_CODE_OAUTH_TOKEN: oauthToken, CLAUDE_CODE_BUBBLEWRAP: '1', }, }; } /** * Pre-warm the Claude CLI subprocess for the next live conversation. Call * fire-and-forget at supervisor boot (and after a conversation ends) so the * first user message doesn't pay CLI startup latency. */ export async function warmUpForLiveConversation( model: string, names?: { botName: string; humanName: string }, ): Promise { if (!model) return; try { const oauthToken = await getClaudeAccessToken(); if (!oauthToken) return; const options = await buildConversationOptions(model, oauthToken, names); await preWarm(options); } catch (err: any) { log.warn(`[conversation] Warm-up skipped: ${err?.message || err}`); } } /** * Start a long-lived conversation. * Creates a single query() with an async input queue. * Messages are pushed via pushMessage(). The query stays alive until endConversation(). */ export async function startConversation( conversationId: string, model: string, onMessage: (type: string, data: any) => void, names?: { botName: string; humanName: string }, recentMessages?: RecentMessage[], ): Promise { log.info(`[conversation] ──── STARTING CONVERSATION ────`); log.info(`[conversation] Conv ID: ${conversationId}`); log.info(`[conversation] Model: ${model}`); // End any existing conversation with this ID if (liveConversations.has(conversationId)) { log.info(`[conversation] Ending existing conversation ${conversationId} before starting new one`); endConversation(conversationId); } const oauthToken = await getClaudeAccessToken(); if (!oauthToken) { log.warn('[conversation] No OAuth token — cannot start'); onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' }); return false; } const baseOptions = await buildConversationOptions(model, oauthToken, names, recentMessages); const systemPromptLen = typeof baseOptions.systemPrompt === 'string' ? baseOptions.systemPrompt.length : 0; log.info(`[conversation] Loaded ${Object.keys(baseOptions.agents || {}).length} sub-agent(s): ${Object.keys(baseOptions.agents || {}).join(', ')}`); if (baseOptions.mcpServers) { log.info(`[conversation] MCP servers: ${Object.keys(baseOptions.mcpServers).join(', ')}`); } const skillNames = Array.isArray(baseOptions.skills) ? baseOptions.skills : []; log.info(`[conversation] Skills: ${skillNames.length ? skillNames.join(', ') : 'none'}`); // Try to claim a pre-warmed subprocess — its abortController is the one // baked into the warm query and must be reused for end/abort to reach it. const claimed = claimWarmup(baseOptions); const abortController = claimed?.abortController ?? new AbortController(); // Create the async input queue const inputQueue = createAsyncQueue(); // Store the conversation const conv: LiveConversation = { id: conversationId, inputQueue, abortController, queryHandle: null, onMessage, busy: false, pendingCount: 0, }; liveConversations.set(conversationId, conv); log.info(`[conversation] System prompt: ${systemPromptLen} chars`); log.info(`[conversation] Starting long-lived query... (${claimed ? 'warm' : 'cold'})`); // Run the for-await loop in the background (fire and forget) (async () => { let fullText = ''; const usedTools = new Set(); let stderrBuf = ''; try { const claudeQuery = claimed ? claimed.warmQuery.query(inputQueue) : query({ prompt: inputQueue, options: { ...baseOptions, abortController, stderr: (chunk: string) => { stderrBuf += chunk; }, }, }); conv.queryHandle = claudeQuery; log.info(`[conversation] ──── QUERY LOOP STARTED ────`); for await (const msg of claudeQuery) { if (abortController.signal.aborted) { log.info(`[conversation] Query aborted — exiting loop`); break; } switch (msg.type) { case 'assistant': { const assistantMsg = msg.message; if (!assistantMsg?.content) break; for (const block of assistantMsg.content) { if (block.type === 'text' && block.text) { if (fullText && !fullText.endsWith('\n')) { fullText += '\n\n'; onMessage('bot:token', { conversationId, token: '\n\n' }); } fullText += block.text; onMessage('bot:token', { conversationId, token: block.text }); } else if (block.type === 'tool_use') { usedTools.add(block.name); onMessage('bot:tool', { conversationId, name: block.name, input: block.input }); } } break; } case 'result': { // Agent finished processing the current message log.info(`[conversation] ──── TURN COMPLETE ────`); log.info(`[conversation] Response length: ${fullText.length} chars`); log.info(`[conversation] Tools used this turn: ${Array.from(usedTools).join(', ') || 'none'}`); if (fullText) { onMessage('bot:response', { conversationId, content: fullText }); fullText = ''; } else if (msg.subtype?.startsWith('error')) { const errorText = (msg as any).errors?.join('; ') || 'Agent turn failed'; log.warn(`[conversation] Turn error: ${errorText}`); onMessage('bot:error', { conversationId, error: errorText }); } // Signal turn complete — backend restart + UI update const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']; const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t)); // Context-size signal for the orchestrator's proactive session recycling. // Prefer modelUsage (carries the per-model contextWindow); fall back to raw usage. let contextTokens = 0; let contextWindow = 0; const modelUsage = (msg as any).modelUsage as Record | undefined; if (modelUsage) { for (const mu of Object.values(modelUsage)) { const used = (mu?.inputTokens || 0) + (mu?.cacheReadInputTokens || 0) + (mu?.cacheCreationInputTokens || 0); if (used > contextTokens) { contextTokens = used; contextWindow = mu?.contextWindow || 0; } } } if (!contextTokens) { const u = (msg as any).usage || {}; contextTokens = (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0); } // One result per pushed message → decrement; idle when nothing else is queued. conv.pendingCount = Math.max(0, (conv.pendingCount || 0) - 1); const idle = conv.pendingCount === 0; onMessage('bot:turn-complete', { conversationId, usedFileTools, contextTokens, contextWindow, idle }); // Reset per-turn state usedTools.clear(); conv.busy = false; log.info(`[conversation] Agent idle — waiting for next message`); break; } case 'tool_progress': onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running', }); break; // ── Background sub-agent events ── case 'system': { const sysMsg = msg as any; if (sysMsg.subtype === 'task_started') { log.info(`[conversation] ──── SUB-AGENT STARTED ────`); log.info(`[conversation] Task ID: ${sysMsg.task_id}`); log.info(`[conversation] Description: ${sysMsg.description}`); onMessage('bot:task-created', { conversationId, taskId: sysMsg.task_id, description: sysMsg.description, type: sysMsg.task_type, }); } else if (sysMsg.subtype === 'task_progress') { const summary = sysMsg.summary || sysMsg.last_tool_name || 'working'; log.info(`[conversation] Sub-agent ${sysMsg.task_id} | ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`); onMessage('bot:task-progress', { conversationId, taskId: sysMsg.task_id, summary, lastTool: sysMsg.last_tool_name, usage: sysMsg.usage, }); } else if (sysMsg.subtype === 'task_notification') { log.info(`[conversation] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`); log.info(`[conversation] Task ID: ${sysMsg.task_id}`); log.info(`[conversation] Status: ${sysMsg.status}`); log.info(`[conversation] Summary: ${sysMsg.summary?.slice(0, 200)}`); log.info(`[conversation] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`); onMessage('bot:task-done', { conversationId, taskId: sysMsg.task_id, status: sysMsg.status, summary: sysMsg.summary, usage: sysMsg.usage, }); // Don't emit bot:turn-complete here. Sub-agent completion is a progress // signal, not a turn boundary — the parent agent will continue, and its // real `result` event (above) is the only true turn end. The parent's // `usedTools` Set already captures sub-agent tool_use blocks, so file // edits made inside a sub-agent are still reflected in usedFileTools. } break; } } } log.info(`[conversation] ──── QUERY LOOP ENDED ────`); // Send any remaining text if (fullText && !abortController.signal.aborted) { onMessage('bot:response', { conversationId, content: fullText }); } } catch (err: any) { if (!abortController.signal.aborted) { const detail = stderrBuf.trim(); const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message; log.warn(`[conversation] Query error: ${errMsg}`); onMessage('bot:error', { conversationId, error: errMsg }); } } finally { log.info(`[conversation] Cleaning up conversation ${conversationId}`); liveConversations.delete(conversationId); onMessage('bot:conversation-ended', { conversationId }); // Pre-warm a fresh subprocess for the next live conversation (fire-and-forget). warmUpForLiveConversation(model, names); } })(); return true; } /** * Push a user message into an existing live conversation. * The agent will process it as part of the ongoing conversation. */ export function pushMessage( conversationId: string, content: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[], ): boolean { const conv = liveConversations.get(conversationId); if (!conv) { log.warn(`[conversation] pushMessage — no live conversation ${conversationId}`); return false; } log.info(`[conversation] ──── PUSH MESSAGE ────`); log.info(`[conversation] Conv: ${conversationId}`); log.info(`[conversation] Content: "${content.slice(0, 100)}..."`); log.info(`[conversation] Attachments: ${attachments?.length || 0}`); log.info(`[conversation] Agent busy: ${conv.busy}`); const userMessage = buildUserMessage(content, attachments, savedFiles); conv.busy = true; conv.pendingCount = (conv.pendingCount || 0) + 1; conv.inputQueue.push(userMessage); // Emit typing indicator conv.onMessage('bot:typing', { conversationId }); return true; } /** End a live conversation */ export function endConversation(conversationId: string): void { const conv = liveConversations.get(conversationId); if (!conv) return; log.info(`[conversation] ──── ENDING CONVERSATION ────`); log.info(`[conversation] Conv: ${conversationId}`); conv.inputQueue.end(); conv.abortController.abort(); liveConversations.delete(conversationId); } /** Check if the agent is currently busy processing a message */ export function isConversationBusy(conversationId: string): boolean { return liveConversations.get(conversationId)?.busy || false; } /** True if ANY live conversation in this harness is mid-turn. Used by the supervisor to defer * backend restarts during channel/Alexa turns (which don't set the dashboard's agentQueryActive). */ export function anyConversationBusy(): boolean { for (const c of liveConversations.values()) if (c.busy) return true; return false; } /** True while any one-shot startBlobyAgentQuery (pulse/cron, customer WhatsApp) is in flight. * These register only in activeQueries (cleared in a finally), not liveConversations, so * anyConversationBusy() can't see them. */ export function anyOneShotActive(): boolean { return activeQueries.size > 0; } /** Stop a specific background sub-agent task */ export async function stopSubAgentTask(conversationId: string, taskId: string): Promise { const conv = liveConversations.get(conversationId); if (conv?.queryHandle?.stopTask) { log.info(`[conversation] Stopping sub-agent task: ${taskId}`); await conv.queryHandle.stopTask(taskId); } else { log.warn(`[conversation] Cannot stop task ${taskId} — no live conversation ${conversationId}`); } } // ── One-shot Query API (backward compat) ──────────────────────────────────── // Used by: customer WhatsApp (handleCustomerMessage), scheduler (triggerAgent) interface ActiveQuery { abortController: AbortController; } const activeQueries = new Map(); /** * Run a one-shot Agent SDK query (classic request-response). * Used for customer-facing messages and scheduler triggers. */ export async function startBlobyAgentQuery( conversationId: string, prompt: string, model: string, onMessage: (type: string, data: any) => void, attachments?: AgentAttachment[], savedFiles?: SavedFile[], names?: { botName: string; humanName: string }, recentMessages?: RecentMessage[], supportPrompt?: string, maxTurns?: number, ): Promise { const oauthToken = await getClaudeAccessToken(); if (!oauthToken) { onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' }); // bot:done frees the caller's slot (WhatsApp activeAgents / scheduler) — this // early return is the only path that skips the finally's guarantee below. onMessage('bot:done', { conversationId, usedFileTools: false }); return; } const abortController = new AbortController(); const memoryFiles = readMemoryFiles(); let enrichedPrompt: string; if (supportPrompt) { enrichedPrompt = supportPrompt; } else { const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'claude'); enrichedPrompt = basePrompt; enrichedPrompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`; try { const { loadConfig: loadCfg } = await import('../../shared/config.js'); const cfg = loadCfg(); const channels = (cfg as any).channels; if (channels) { enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``; } } catch {} } if (recentMessages?.length) { enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`; } activeQueries.set(conversationId, { abortController }); // Hard watchdog: a hung CLI subprocess (network stall, stuck MCP) would otherwise leave the // `for await` loop pending forever — the finally never runs, bot:done never fires, and the // caller's per-conversation slot (WhatsApp activeAgents / scheduler) is pinned for good. Abort // after 5 min so the finally always emits bot:done. Cleared on normal completion. const watchdog = setTimeout(() => { log.warn(`[bloby-agent] One-shot query timed out (5m) — aborting conv=${conversationId}`); abortController.abort(); }, 300_000); let fullText = ''; const usedTools = new Set(); let stderrBuf = ''; let plainPrompt = prompt; if (savedFiles?.length && !attachments?.length) { const note = buildSavedFilesNote(savedFiles); if (note) plainPrompt += `\n\n${note}`; } const sdkPrompt: string | AsyncIterable = attachments?.length ? (async function* () { yield buildUserMessage(prompt, attachments, savedFiles); })() : plainPrompt; try { const mcpServers = loadMcpServers(); const effectiveMaxTurns = maxTurns ?? 50; log.info(`[bloby-agent] One-shot query: conv=${conversationId}, maxTurns=${effectiveMaxTurns}`); const claudeQuery = query({ prompt: sdkPrompt, options: { model, effort: 'high', // see buildConversationOptions — token-efficient deep reasoning cwd: WORKSPACE_DIR, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, maxTurns: effectiveMaxTurns, abortController, systemPrompt: enrichedPrompt, // Customer-facing runs (supportPrompt) get no skills — SCRIPT.md alone // governs that persona and the listing would just leak ops docs into // customer context. Owner runs (pulse/cron) get the full workspace set. skills: supportPrompt ? [] : syncClaudeSkills(), mcpServers, stderr: (chunk: string) => { stderrBuf += chunk; }, env: { ...process.env as Record, CLAUDE_CODE_OAUTH_TOKEN: oauthToken, CLAUDE_CODE_BUBBLEWRAP: '1', }, }, }); onMessage('bot:typing', { conversationId }); for await (const msg of claudeQuery) { if (abortController.signal.aborted) break; switch (msg.type) { case 'assistant': { const assistantMsg = msg.message; if (!assistantMsg?.content) break; for (const block of assistantMsg.content) { if (block.type === 'text' && block.text) { if (fullText && !fullText.endsWith('\n')) { fullText += '\n\n'; onMessage('bot:token', { conversationId, token: '\n\n' }); } fullText += block.text; onMessage('bot:token', { conversationId, token: block.text }); } else if (block.type === 'tool_use') { usedTools.add(block.name); onMessage('bot:tool', { conversationId, name: block.name, input: block.input }); } } break; } case 'result': { if (fullText) { onMessage('bot:response', { conversationId, content: fullText }); fullText = ''; } else if (msg.subtype?.startsWith('error')) { onMessage('bot:error', { conversationId, error: (msg as any).errors?.join('; ') || 'Agent query failed' }); } break; } case 'tool_progress': onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running' }); break; } } if (fullText && !abortController.signal.aborted) { onMessage('bot:response', { conversationId, content: fullText }); } } catch (err: any) { if (!abortController.signal.aborted) { const detail = stderrBuf.trim(); const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message; log.warn(`Bloby agent error (${conversationId}): ${errMsg}`); onMessage('bot:error', { conversationId, error: errMsg }); } } finally { clearTimeout(watchdog); activeQueries.delete(conversationId); const FILE_TOOLS = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit']; const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t)); onMessage('bot:done', { conversationId, usedFileTools }); } } /** Stop a one-shot query */ export function stopBlobyAgentQuery(conversationId: string): void { const q = activeQueries.get(conversationId); if (q) { q.abortController.abort(); activeQueries.delete(conversationId); } } // ── Workspace agent endpoint (POST /api/agent/query) ────────────────────── export async function runAgentQuery(req: AgentQueryRequest): Promise { const oauthToken = await getClaudeAccessToken(); if (!oauthToken) { return { ok: false, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' }; } const maxTurns = Math.min(Math.max(req.maxTurns || 25, 1), 50); const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000); // Empty/missing systemPrompt → fall back to Claude's built-in `claude_code` // preset (its native coding-agent prompt + tools). const systemPrompt: string | { type: 'preset'; preset: 'claude_code' } = req.systemPrompt ? req.systemPrompt : { type: 'preset', preset: 'claude_code' }; const abortController = new AbortController(); const timeoutHandle = setTimeout(() => abortController.abort(), timeout); let fullText = ''; const usedTools = new Set(); let sessionId: string | undefined; let stderrBuf = ''; try { log.info(`[claude/agent-api] Query: msg="${req.message.slice(0, 80)}..." maxTurns=${maxTurns} timeout=${timeout}ms resume=${req.sessionId || 'none'}`); const claudeQuery = query({ prompt: req.message, options: { cwd: WORKSPACE_DIR, effort: 'high', // see buildConversationOptions — token-efficient deep reasoning permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, maxTurns, abortController, systemPrompt: systemPrompt as any, skills: syncClaudeSkills(), ...(req.sessionId ? { resume: req.sessionId } : {}), stderr: (chunk: string) => { stderrBuf += chunk; }, env: { ...process.env as Record, CLAUDE_CODE_OAUTH_TOKEN: oauthToken, CLAUDE_CODE_BUBBLEWRAP: '1', }, }, }); for await (const msg of claudeQuery) { if (abortController.signal.aborted) break; switch (msg.type) { case 'assistant': { const assistantMsg = (msg as any).message; if (!assistantMsg?.content) break; for (const block of assistantMsg.content) { if (block.type === 'text' && block.text) { if (fullText && !fullText.endsWith('\n')) fullText += '\n\n'; fullText += block.text; } else if (block.type === 'tool_use') { usedTools.add(block.name); } } break; } case 'result': { sessionId = (msg as any).session_id; if (!fullText && (msg as any).subtype?.startsWith('error')) { return { ok: false, error: (msg as any).errors?.join('; ') || 'Agent query failed', sessionId, toolsUsed: Array.from(usedTools), }; } break; } } } const usedFileTools = ['Write', 'Edit', 'MultiEdit', 'NotebookEdit'].some((t) => usedTools.has(t)); log.info(`[claude/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], session=${sessionId || 'unknown'}`); return { ok: true, response: fullText, sessionId, toolsUsed: Array.from(usedTools), usedFileTools }; } catch (err: any) { if (abortController.signal.aborted) return { ok: false, error: 'Query timed out.', sessionId }; const detail = stderrBuf.trim(); const errMsg = detail ? `${err.message}\n\n${detail}` : err.message; log.warn(`[claude/agent-api] Error: ${errMsg}`); return { ok: false, error: errMsg, sessionId }; } finally { clearTimeout(timeoutHandle); } }