bloby-bot 0.20.8 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,13 @@
1
1
  /**
2
- * Lightweight Claude Agent SDK wrapper for the supervisor's bloby chat.
3
- * Fresh context per turn — memory files and conversation history are injected into the system prompt.
2
+ * Claude Agent SDK wrapper v2 Long-lived Query Model
3
+ *
4
+ * Two modes:
5
+ * 1. Live Conversation (main chat + admin WhatsApp):
6
+ * Single long-lived query() per conversation. User messages pushed into async queue.
7
+ * Agent stays alive, processes messages as they arrive, reports sub-agent completions.
8
+ *
9
+ * 2. One-shot Query (customer WhatsApp, scheduler):
10
+ * Classic request-response: one query() per message. Backward compat.
4
11
  */
5
12
 
6
13
  import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
@@ -13,18 +20,13 @@ import { getClaudeAccessToken } from '../worker/claude-auth.js';
13
20
  import { assembleSystemPrompt } from '../worker/prompts/prompt-assembler.js';
14
21
  import { buildAgents } from './agents/index.js';
15
22
 
23
+ // ── Types ──────────────────────────────────────────────────────────────────
24
+
16
25
  export interface RecentMessage {
17
26
  role: 'user' | 'assistant';
18
27
  content: string;
19
28
  }
20
29
 
21
- interface ActiveQuery {
22
- abortController: AbortController;
23
- queryHandle?: any; // SDK query handle for stopTask()
24
- }
25
-
26
- const activeQueries = new Map<string, ActiveQuery>();
27
-
28
30
  export interface AgentAttachment {
29
31
  type: 'image' | 'file';
30
32
  name: string;
@@ -32,6 +34,68 @@ export interface AgentAttachment {
32
34
  data: string; // base64
33
35
  }
34
36
 
37
+ // ── Async Queue ────────────────────────────────────────────────────────────
38
+
39
+ interface AsyncQueue<T> extends AsyncIterable<T> {
40
+ push(item: T): void;
41
+ end(): void;
42
+ }
43
+
44
+ /** Create an async queue that can be used as an AsyncIterable prompt for the SDK */
45
+ function createAsyncQueue<T>(): AsyncQueue<T> {
46
+ const pending: T[] = [];
47
+ let resolve: ((value: IteratorResult<T>) => void) | null = null;
48
+ let done = false;
49
+
50
+ return {
51
+ push(item: T) {
52
+ if (done) return;
53
+ if (resolve) {
54
+ resolve({ value: item, done: false });
55
+ resolve = null;
56
+ } else {
57
+ pending.push(item);
58
+ }
59
+ },
60
+ end() {
61
+ done = true;
62
+ if (resolve) resolve({ value: undefined as any, done: true });
63
+ },
64
+ [Symbol.asyncIterator]() {
65
+ return {
66
+ next(): Promise<IteratorResult<T>> {
67
+ if (pending.length > 0) {
68
+ return Promise.resolve({ value: pending.shift()!, done: false });
69
+ }
70
+ if (done) return Promise.resolve({ value: undefined as any, done: true });
71
+ return new Promise((r) => { resolve = r; });
72
+ },
73
+ };
74
+ },
75
+ };
76
+ }
77
+
78
+ // ── Live Conversation Manager ──────────────────────────────────────────────
79
+
80
+ interface LiveConversation {
81
+ id: string;
82
+ inputQueue: AsyncQueue<SDKUserMessage>;
83
+ abortController: AbortController;
84
+ queryHandle: any;
85
+ onMessage: (type: string, data: any) => void;
86
+ /** True while the model is actively processing (between message push and result) */
87
+ busy: boolean;
88
+ }
89
+
90
+ const liveConversations = new Map<string, LiveConversation>();
91
+
92
+ /** Check if a live conversation exists */
93
+ export function hasConversation(conversationId: string): boolean {
94
+ return liveConversations.has(conversationId);
95
+ }
96
+
97
+ // ── Helpers ─────────────────────────────────────────────────────────────────
98
+
35
99
  /** Read a memory file from workspace, returning '(empty)' if missing or empty */
36
100
  function readMemoryFile(filename: string): string {
37
101
  try {
@@ -42,8 +106,8 @@ function readMemoryFile(filename: string): string {
42
106
  }
43
107
  }
44
108
 
45
- /** Read all memory + config files and return their contents */
46
- function readMemoryFiles(): { myself: string; myhuman: string; memory: string; pulse: string; crons: string } {
109
+ /** Read all memory + config files */
110
+ function readMemoryFiles() {
47
111
  return {
48
112
  myself: readMemoryFile('MYSELF.md'),
49
113
  myhuman: readMemoryFile('MYHUMAN.md'),
@@ -59,11 +123,25 @@ function formatConversationHistory(messages: RecentMessage[]): string {
59
123
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
60
124
  }
61
125
 
62
- /** Build a multi-part prompt with attachments for the SDK */
63
- function buildMultiPartPrompt(text: string, attachments: AgentAttachment[], savedFiles?: SavedFile[]): AsyncIterable<SDKUserMessage> {
64
- return (async function* () {
65
- const content: any[] = [];
126
+ /** Load MCP server config from workspace/MCP.json */
127
+ function loadMcpServers(): Record<string, any> | undefined {
128
+ try {
129
+ const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
130
+ const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
131
+ if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
132
+ return mcpConfig;
133
+ } else if (Array.isArray(mcpConfig) && mcpConfig.length) {
134
+ return Object.assign({}, ...mcpConfig);
135
+ }
136
+ } catch {}
137
+ return undefined;
138
+ }
139
+
140
+ /** Build an SDKUserMessage from text + optional attachments */
141
+ function buildUserMessage(text: string, attachments?: AgentAttachment[], savedFiles?: SavedFile[]): SDKUserMessage {
142
+ const content: any[] = [];
66
143
 
144
+ if (attachments?.length) {
67
145
  for (const att of attachments) {
68
146
  if (att.type === 'image') {
69
147
  content.push({
@@ -77,28 +155,338 @@ function buildMultiPartPrompt(text: string, attachments: AgentAttachment[], save
77
155
  });
78
156
  }
79
157
  }
158
+ }
159
+
160
+ let promptText = text || '(attached files)';
161
+ if (savedFiles?.length) {
162
+ const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
163
+ promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
164
+ }
165
+
166
+ content.push({ type: 'text', text: promptText });
167
+
168
+ return {
169
+ type: 'user' as const,
170
+ message: { role: 'user' as const, content },
171
+ parent_tool_use_id: null,
172
+ } as SDKUserMessage;
173
+ }
174
+
175
+ // ── Live Conversation API ──────────────────────────────────────────────────
80
176
 
81
- // Append local file paths so Claude can reference them with tools
82
- let promptText = text || '(attached files)';
83
- if (savedFiles?.length) {
84
- const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
85
- promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
177
+ /**
178
+ * Start a long-lived conversation.
179
+ * Creates a single query() with an async input queue.
180
+ * Messages are pushed via pushMessage(). The query stays alive until endConversation().
181
+ */
182
+ export async function startConversation(
183
+ conversationId: string,
184
+ model: string,
185
+ onMessage: (type: string, data: any) => void,
186
+ names?: { botName: string; humanName: string },
187
+ recentMessages?: RecentMessage[],
188
+ ): Promise<boolean> {
189
+ log.info(`[conversation] ──── STARTING CONVERSATION ────`);
190
+ log.info(`[conversation] Conv ID: ${conversationId}`);
191
+ log.info(`[conversation] Model: ${model}`);
192
+
193
+ // End any existing conversation with this ID
194
+ if (liveConversations.has(conversationId)) {
195
+ log.info(`[conversation] Ending existing conversation ${conversationId} before starting new one`);
196
+ endConversation(conversationId);
197
+ }
198
+
199
+ const oauthToken = await getClaudeAccessToken();
200
+ if (!oauthToken) {
201
+ log.warn('[conversation] No OAuth token — cannot start');
202
+ onMessage('bot:error', { conversationId, error: 'Claude OAuth token not found. Please authenticate via the dashboard.' });
203
+ return false;
204
+ }
205
+
206
+ // Assemble system prompt (once for the conversation lifetime)
207
+ const memoryFiles = readMemoryFiles();
208
+ const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
209
+ let systemPrompt = basePrompt;
210
+ 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}`;
211
+
212
+ // Inject channel config
213
+ try {
214
+ const { loadConfig: loadCfg } = await import('../shared/config.js');
215
+ const cfg = loadCfg();
216
+ const channels = (cfg as any).channels;
217
+ if (channels) {
218
+ systemPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
86
219
  }
220
+ } catch {}
221
+
222
+ // Inject recent conversation history for context continuity
223
+ if (recentMessages?.length) {
224
+ systemPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
225
+ }
226
+
227
+ // Build sub-agent definitions
228
+ const agents = buildAgents();
229
+ log.info(`[conversation] Loaded ${Object.keys(agents).length} sub-agent(s): ${Object.keys(agents).join(', ')}`);
230
+
231
+ // Load MCP servers
232
+ const mcpServers = loadMcpServers();
233
+ if (mcpServers) {
234
+ log.info(`[conversation] MCP servers: ${Object.keys(mcpServers).join(', ')}`);
235
+ }
236
+
237
+ // Create the async input queue
238
+ const inputQueue = createAsyncQueue<SDKUserMessage>();
239
+ const abortController = new AbortController();
240
+
241
+ // Store the conversation
242
+ const conv: LiveConversation = {
243
+ id: conversationId,
244
+ inputQueue,
245
+ abortController,
246
+ queryHandle: null,
247
+ onMessage,
248
+ busy: false,
249
+ };
250
+ liveConversations.set(conversationId, conv);
251
+
252
+ log.info(`[conversation] System prompt: ${systemPrompt.length} chars`);
253
+ log.info(`[conversation] Starting long-lived query...`);
254
+
255
+ // Run the for-await loop in the background (fire and forget)
256
+ (async () => {
257
+ let fullText = '';
258
+ const usedTools = new Set<string>();
259
+ let stderrBuf = '';
260
+
261
+ try {
262
+ const claudeQuery = query({
263
+ prompt: inputQueue,
264
+ options: {
265
+ model,
266
+ cwd: WORKSPACE_DIR,
267
+ permissionMode: 'bypassPermissions',
268
+ allowDangerouslySkipPermissions: true,
269
+ abortController,
270
+ systemPrompt,
271
+ mcpServers,
272
+ agents,
273
+ agentProgressSummaries: true,
274
+ stderr: (chunk: string) => { stderrBuf += chunk; },
275
+ env: {
276
+ ...process.env as Record<string, string>,
277
+ CLAUDE_CODE_OAUTH_TOKEN: oauthToken,
278
+ CLAUDE_CODE_BUBBLEWRAP: '1',
279
+ },
280
+ },
281
+ });
282
+
283
+ conv.queryHandle = claudeQuery;
284
+ log.info(`[conversation] ──── QUERY LOOP STARTED ────`);
285
+
286
+ for await (const msg of claudeQuery) {
287
+ if (abortController.signal.aborted) {
288
+ log.info(`[conversation] Query aborted — exiting loop`);
289
+ break;
290
+ }
291
+
292
+ switch (msg.type) {
293
+ case 'assistant': {
294
+ const assistantMsg = msg.message;
295
+ if (!assistantMsg?.content) break;
296
+
297
+ for (const block of assistantMsg.content) {
298
+ if (block.type === 'text' && block.text) {
299
+ if (fullText && !fullText.endsWith('\n')) {
300
+ fullText += '\n\n';
301
+ onMessage('bot:token', { conversationId, token: '\n\n' });
302
+ }
303
+ fullText += block.text;
304
+ onMessage('bot:token', { conversationId, token: block.text });
305
+ } else if (block.type === 'tool_use') {
306
+ usedTools.add(block.name);
307
+ onMessage('bot:tool', { conversationId, name: block.name, input: block.input });
308
+ }
309
+ }
310
+ break;
311
+ }
87
312
 
88
- content.push({ type: 'text', text: promptText });
313
+ case 'result': {
314
+ // Agent finished processing the current message
315
+ log.info(`[conversation] ──── TURN COMPLETE ────`);
316
+ log.info(`[conversation] Response length: ${fullText.length} chars`);
317
+ log.info(`[conversation] Tools used this turn: ${Array.from(usedTools).join(', ') || 'none'}`);
318
+
319
+ if (fullText) {
320
+ onMessage('bot:response', { conversationId, content: fullText });
321
+ fullText = '';
322
+ } else if (msg.subtype?.startsWith('error')) {
323
+ const errorText = (msg as any).errors?.join('; ') || 'Agent turn failed';
324
+ log.warn(`[conversation] Turn error: ${errorText}`);
325
+ onMessage('bot:error', { conversationId, error: errorText });
326
+ }
327
+
328
+ // Signal turn complete — backend restart + UI update
329
+ const FILE_TOOLS = ['Write', 'Edit'];
330
+ const usedFileTools = FILE_TOOLS.some((t) => usedTools.has(t));
331
+ onMessage('bot:turn-complete', { conversationId, usedFileTools });
332
+
333
+ // Reset per-turn state
334
+ usedTools.clear();
335
+ conv.busy = false;
336
+ log.info(`[conversation] Agent idle — waiting for next message`);
337
+ break;
338
+ }
339
+
340
+ case 'tool_progress':
341
+ onMessage('bot:tool', {
342
+ conversationId,
343
+ name: (msg as any).tool_name || 'working',
344
+ status: 'running',
345
+ });
346
+ break;
347
+
348
+ // ── Background sub-agent events ──
349
+ case 'system': {
350
+ const sysMsg = msg as any;
351
+ if (sysMsg.subtype === 'task_started') {
352
+ log.info(`[conversation] ──── SUB-AGENT STARTED ────`);
353
+ log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
354
+ log.info(`[conversation] Description: ${sysMsg.description}`);
355
+ onMessage('bot:task-created', {
356
+ conversationId,
357
+ taskId: sysMsg.task_id,
358
+ description: sysMsg.description,
359
+ type: sysMsg.task_type,
360
+ });
361
+ } else if (sysMsg.subtype === 'task_progress') {
362
+ const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
363
+ log.info(`[conversation] Sub-agent ${sysMsg.task_id} | ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
364
+ onMessage('bot:task-progress', {
365
+ conversationId,
366
+ taskId: sysMsg.task_id,
367
+ summary,
368
+ lastTool: sysMsg.last_tool_name,
369
+ usage: sysMsg.usage,
370
+ });
371
+ } else if (sysMsg.subtype === 'task_notification') {
372
+ log.info(`[conversation] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
373
+ log.info(`[conversation] Task ID: ${sysMsg.task_id}`);
374
+ log.info(`[conversation] Status: ${sysMsg.status}`);
375
+ log.info(`[conversation] Summary: ${sysMsg.summary?.slice(0, 200)}`);
376
+ 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`);
377
+ onMessage('bot:task-done', {
378
+ conversationId,
379
+ taskId: sysMsg.task_id,
380
+ status: sysMsg.status,
381
+ summary: sysMsg.summary,
382
+ usage: sysMsg.usage,
383
+ });
384
+ // Sub-agent completion may have written files
385
+ if (sysMsg.status === 'completed') {
386
+ onMessage('bot:turn-complete', { conversationId, usedFileTools: true });
387
+ }
388
+ }
389
+ break;
390
+ }
391
+ }
392
+ }
393
+
394
+ log.info(`[conversation] ──── QUERY LOOP ENDED ────`);
89
395
 
90
- yield {
91
- type: 'user' as const,
92
- message: { role: 'user' as const, content },
93
- parent_tool_use_id: null,
94
- } as SDKUserMessage;
396
+ // Send any remaining text
397
+ if (fullText && !abortController.signal.aborted) {
398
+ onMessage('bot:response', { conversationId, content: fullText });
399
+ }
400
+ } catch (err: any) {
401
+ if (!abortController.signal.aborted) {
402
+ const detail = stderrBuf.trim();
403
+ const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
404
+ log.warn(`[conversation] Query error: ${errMsg}`);
405
+ onMessage('bot:error', { conversationId, error: errMsg });
406
+ }
407
+ } finally {
408
+ log.info(`[conversation] Cleaning up conversation ${conversationId}`);
409
+ liveConversations.delete(conversationId);
410
+ onMessage('bot:conversation-ended', { conversationId });
411
+ }
95
412
  })();
413
+
414
+ return true;
415
+ }
416
+
417
+ /**
418
+ * Push a user message into an existing live conversation.
419
+ * The agent will process it as part of the ongoing conversation.
420
+ */
421
+ export function pushMessage(
422
+ conversationId: string,
423
+ content: string,
424
+ attachments?: AgentAttachment[],
425
+ savedFiles?: SavedFile[],
426
+ ): boolean {
427
+ const conv = liveConversations.get(conversationId);
428
+ if (!conv) {
429
+ log.warn(`[conversation] pushMessage — no live conversation ${conversationId}`);
430
+ return false;
431
+ }
432
+
433
+ log.info(`[conversation] ──── PUSH MESSAGE ────`);
434
+ log.info(`[conversation] Conv: ${conversationId}`);
435
+ log.info(`[conversation] Content: "${content.slice(0, 100)}..."`);
436
+ log.info(`[conversation] Attachments: ${attachments?.length || 0}`);
437
+ log.info(`[conversation] Agent busy: ${conv.busy}`);
438
+
439
+ const userMessage = buildUserMessage(content, attachments, savedFiles);
440
+ conv.busy = true;
441
+ conv.inputQueue.push(userMessage);
442
+
443
+ // Emit typing indicator
444
+ conv.onMessage('bot:typing', { conversationId });
445
+
446
+ return true;
447
+ }
448
+
449
+ /** End a live conversation */
450
+ export function endConversation(conversationId: string): void {
451
+ const conv = liveConversations.get(conversationId);
452
+ if (!conv) return;
453
+
454
+ log.info(`[conversation] ──── ENDING CONVERSATION ────`);
455
+ log.info(`[conversation] Conv: ${conversationId}`);
456
+
457
+ conv.inputQueue.end();
458
+ conv.abortController.abort();
459
+ liveConversations.delete(conversationId);
460
+ }
461
+
462
+ /** Check if the agent is currently busy processing a message */
463
+ export function isConversationBusy(conversationId: string): boolean {
464
+ return liveConversations.get(conversationId)?.busy || false;
465
+ }
466
+
467
+ /** Stop a specific background sub-agent task */
468
+ export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
469
+ const conv = liveConversations.get(conversationId);
470
+ if (conv?.queryHandle?.stopTask) {
471
+ log.info(`[conversation] Stopping sub-agent task: ${taskId}`);
472
+ await conv.queryHandle.stopTask(taskId);
473
+ } else {
474
+ log.warn(`[conversation] Cannot stop task ${taskId} — no live conversation ${conversationId}`);
475
+ }
476
+ }
477
+
478
+ // ── One-shot Query API (backward compat) ────────────────────────────────────
479
+ // Used by: customer WhatsApp (handleCustomerMessage), scheduler (triggerAgent)
480
+
481
+ interface ActiveQuery {
482
+ abortController: AbortController;
96
483
  }
97
484
 
485
+ const activeQueries = new Map<string, ActiveQuery>();
98
486
 
99
487
  /**
100
- * Run an Agent SDK query for a bloby chat conversation.
101
- * Fresh context each turn — memory files and history are injected into the system prompt.
488
+ * Run a one-shot Agent SDK query (classic request-response).
489
+ * Used for customer-facing messages and scheduler triggers.
102
490
  */
103
491
  export async function startBlobyAgentQuery(
104
492
  conversationId: string,
@@ -109,9 +497,7 @@ export async function startBlobyAgentQuery(
109
497
  savedFiles?: SavedFile[],
110
498
  names?: { botName: string; humanName: string },
111
499
  recentMessages?: RecentMessage[],
112
- /** Override system prompt (used for customer-facing channel messages via SUPPORT.md) */
113
500
  supportPrompt?: string,
114
- /** Max agentic turns. Default 50. Orchestrator uses 5. */
115
501
  maxTurns?: number,
116
502
  ): Promise<void> {
117
503
  const oauthToken = await getClaudeAccessToken();
@@ -123,19 +509,14 @@ export async function startBlobyAgentQuery(
123
509
  const abortController = new AbortController();
124
510
  const memoryFiles = readMemoryFiles();
125
511
 
126
- // Build enriched system prompt
127
512
  let enrichedPrompt: string;
128
513
  if (supportPrompt) {
129
- // Customer-facing: SCRIPT.md is the ENTIRE prompt. Nothing else.
130
- // No memory files, no skill instructions, no config — just the script + conversation history.
131
514
  enrichedPrompt = supportPrompt;
132
515
  } else {
133
- // Admin/chat: full main system prompt with all context (dynamic fragments applied)
134
516
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName);
135
517
  enrichedPrompt = basePrompt;
136
518
  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}`;
137
519
 
138
- // Inject channel config (admin only)
139
520
  try {
140
521
  const { loadConfig: loadCfg } = await import('../shared/config.js');
141
522
  const cfg = loadCfg();
@@ -144,19 +525,18 @@ export async function startBlobyAgentQuery(
144
525
  enrichedPrompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
145
526
  }
146
527
  } catch {}
147
-
148
- // Task board is now managed natively by the SDK via background agents
149
528
  }
150
529
 
151
530
  if (recentMessages?.length) {
152
531
  enrichedPrompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
153
532
  }
154
533
 
534
+ activeQueries.set(conversationId, { abortController });
535
+
155
536
  let fullText = '';
156
537
  const usedTools = new Set<string>();
157
538
  let stderrBuf = '';
158
539
 
159
- // If there are saved files but no inline attachments, append path info to plain text prompt
160
540
  let plainPrompt = prompt;
161
541
  if (savedFiles?.length && !attachments?.length) {
162
542
  const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
@@ -164,43 +544,15 @@ export async function startBlobyAgentQuery(
164
544
  }
165
545
 
166
546
  const sdkPrompt: string | AsyncIterable<SDKUserMessage> =
167
- attachments?.length ? buildMultiPartPrompt(prompt, attachments, savedFiles) : plainPrompt;
168
-
169
- // Skills are discovered natively by the Claude Agent SDK via .claude-plugin/plugin.json
170
- // in each skill folder. No manual injection needed — the SDK handles lazy loading.
171
- // Customer mode uses SCRIPT.md exclusively (passed via supportPrompt parameter).
547
+ attachments?.length ? (async function* () {
548
+ yield buildUserMessage(prompt, attachments, savedFiles);
549
+ })() : plainPrompt;
172
550
 
173
551
  try {
174
-
175
- // Load MCP server config from workspace/MCP.json if it exists
176
- // Format: { "server-name": { command, args, env }, ... } (object, not array)
177
- let mcpServers: Record<string, any> | undefined;
178
- try {
179
- const mcpConfigPath = path.join(WORKSPACE_DIR, 'MCP.json');
180
- const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
181
- if (mcpConfig && typeof mcpConfig === 'object' && !Array.isArray(mcpConfig) && Object.keys(mcpConfig).length) {
182
- // Already in correct format: { "name": { command, args, env } }
183
- mcpServers = mcpConfig;
184
- } else if (Array.isArray(mcpConfig) && mcpConfig.length) {
185
- // Legacy array format: merge all entries into a single object
186
- mcpServers = Object.assign({}, ...mcpConfig);
187
- }
188
- if (mcpServers) {
189
- const names = Object.keys(mcpServers).join(', ');
190
- log.info(`Loaded MCP server(s): [${names}]`);
191
- }
192
- } catch {}
193
-
552
+ const mcpServers = loadMcpServers();
194
553
  const effectiveMaxTurns = maxTurns ?? 50;
195
554
 
196
- // Build sub-agent definitions (only for orchestrator/admin, not customer support)
197
- let agents: Record<string, any> | undefined;
198
- if (!supportPrompt && effectiveMaxTurns <= 10) {
199
- agents = buildAgents();
200
- log.info(`[bloby-agent] Orchestrator mode — loaded ${Object.keys(agents).length} sub-agent(s): ${Object.keys(agents).join(', ')}`);
201
- }
202
-
203
- log.info(`[bloby-agent] Starting query: conv=${conversationId}, model=${model}, maxTurns=${effectiveMaxTurns}, agents=${agents ? Object.keys(agents).join(',') : 'none'}, promptLen=${enrichedPrompt.length}`);
555
+ log.info(`[bloby-agent] One-shot query: conv=${conversationId}, maxTurns=${effectiveMaxTurns}`);
204
556
 
205
557
  const claudeQuery = query({
206
558
  prompt: sdkPrompt,
@@ -213,8 +565,6 @@ export async function startBlobyAgentQuery(
213
565
  abortController,
214
566
  systemPrompt: enrichedPrompt,
215
567
  mcpServers,
216
- ...(agents && { agents }),
217
- ...(agents && { agentProgressSummaries: true }),
218
568
  stderr: (chunk: string) => { stderrBuf += chunk; },
219
569
  env: {
220
570
  ...process.env as Record<string, string>,
@@ -224,9 +574,6 @@ export async function startBlobyAgentQuery(
224
574
  },
225
575
  });
226
576
 
227
- // Store query handle for stopTask() support
228
- activeQueries.set(conversationId, { abortController, queryHandle: claudeQuery });
229
-
230
577
  onMessage('bot:typing', { conversationId });
231
578
 
232
579
  for await (const msg of claudeQuery) {
@@ -236,10 +583,8 @@ export async function startBlobyAgentQuery(
236
583
  case 'assistant': {
237
584
  const assistantMsg = msg.message;
238
585
  if (!assistantMsg?.content) break;
239
-
240
586
  for (const block of assistantMsg.content) {
241
587
  if (block.type === 'text' && block.text) {
242
- // Add separator between text from different assistant turns
243
588
  if (fullText && !fullText.endsWith('\n')) {
244
589
  fullText += '\n\n';
245
590
  onMessage('bot:token', { conversationId, token: '\n\n' });
@@ -253,83 +598,30 @@ export async function startBlobyAgentQuery(
253
598
  }
254
599
  break;
255
600
  }
256
-
257
601
  case 'result': {
258
602
  if (fullText) {
259
603
  onMessage('bot:response', { conversationId, content: fullText });
260
- fullText = ''; // prevent duplicate
604
+ fullText = '';
261
605
  } else if (msg.subtype?.startsWith('error')) {
262
- const errorText = (msg as any).errors?.join('; ') || 'Agent query failed';
263
- onMessage('bot:error', { conversationId, error: errorText });
606
+ onMessage('bot:error', { conversationId, error: (msg as any).errors?.join('; ') || 'Agent query failed' });
264
607
  }
265
608
  break;
266
609
  }
267
-
268
610
  case 'tool_progress':
269
- onMessage('bot:tool', {
270
- conversationId,
271
- name: (msg as any).tool_name || 'working',
272
- status: 'running',
273
- });
274
- break;
275
-
276
- // ── Background sub-agent events (SDK-managed) ──
277
- case 'system': {
278
- const sysMsg = msg as any;
279
- if (sysMsg.subtype === 'task_started') {
280
- log.info(`[bloby-agent] ──── SUB-AGENT STARTED ────`);
281
- log.info(`[bloby-agent] Task ID: ${sysMsg.task_id}`);
282
- log.info(`[bloby-agent] Description: ${sysMsg.description}`);
283
- log.info(`[bloby-agent] Type: ${sysMsg.task_type || 'agent'}`);
284
- onMessage('bot:task-created', {
285
- conversationId,
286
- taskId: sysMsg.task_id,
287
- description: sysMsg.description,
288
- type: sysMsg.task_type,
289
- });
290
- } else if (sysMsg.subtype === 'task_progress') {
291
- const summary = sysMsg.summary || sysMsg.last_tool_name || 'working';
292
- log.info(`[bloby-agent] Sub-agent ${sysMsg.task_id} | Progress: ${summary} | Tools: ${sysMsg.usage?.tool_uses || 0} | ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
293
- onMessage('bot:task-progress', {
294
- conversationId,
295
- taskId: sysMsg.task_id,
296
- summary,
297
- lastTool: sysMsg.last_tool_name,
298
- usage: sysMsg.usage,
299
- });
300
- } else if (sysMsg.subtype === 'task_notification') {
301
- log.info(`[bloby-agent] ──── SUB-AGENT ${sysMsg.status?.toUpperCase()} ────`);
302
- log.info(`[bloby-agent] Task ID: ${sysMsg.task_id}`);
303
- log.info(`[bloby-agent] Status: ${sysMsg.status}`);
304
- log.info(`[bloby-agent] Summary: ${sysMsg.summary?.slice(0, 200)}`);
305
- log.info(`[bloby-agent] Tokens: ${sysMsg.usage?.total_tokens || 0} | Tools: ${sysMsg.usage?.tool_uses || 0} | Duration: ${Math.round((sysMsg.usage?.duration_ms || 0) / 1000)}s`);
306
- onMessage('bot:task-done', {
307
- conversationId,
308
- taskId: sysMsg.task_id,
309
- status: sysMsg.status,
310
- summary: sysMsg.summary,
311
- usage: sysMsg.usage,
312
- });
313
- // If the sub-agent wrote files, flag it
314
- if (sysMsg.status === 'completed') {
315
- usedTools.add('Write'); // ensure backend restart
316
- }
317
- }
611
+ onMessage('bot:tool', { conversationId, name: (msg as any).tool_name || 'working', status: 'running' });
318
612
  break;
319
- }
320
613
  }
321
614
  }
322
615
 
323
- // If we accumulated text but didn't hit a result message, send what we have
324
616
  if (fullText && !abortController.signal.aborted) {
325
617
  onMessage('bot:response', { conversationId, content: fullText });
326
618
  }
327
619
  } catch (err: any) {
328
620
  if (!abortController.signal.aborted) {
329
621
  const detail = stderrBuf.trim();
330
- const msg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
331
- log.warn(`Bloby agent error (${conversationId}): ${msg}`);
332
- onMessage('bot:error', { conversationId, error: msg });
622
+ const errMsg = detail ? `${err.message}\n\nCLI stderr:\n${detail}` : err.message;
623
+ log.warn(`Bloby agent error (${conversationId}): ${errMsg}`);
624
+ onMessage('bot:error', { conversationId, error: errMsg });
333
625
  }
334
626
  } finally {
335
627
  activeQueries.delete(conversationId);
@@ -339,7 +631,7 @@ export async function startBlobyAgentQuery(
339
631
  }
340
632
  }
341
633
 
342
- /** Stop an in-flight query */
634
+ /** Stop a one-shot query */
343
635
  export function stopBlobyAgentQuery(conversationId: string): void {
344
636
  const q = activeQueries.get(conversationId);
345
637
  if (q) {
@@ -347,14 +639,3 @@ export function stopBlobyAgentQuery(conversationId: string): void {
347
639
  activeQueries.delete(conversationId);
348
640
  }
349
641
  }
350
-
351
- /** Stop a specific background sub-agent task */
352
- export async function stopSubAgentTask(conversationId: string, taskId: string): Promise<void> {
353
- const q = activeQueries.get(conversationId);
354
- if (q?.queryHandle?.stopTask) {
355
- log.info(`[bloby-agent] Stopping sub-agent task: ${taskId}`);
356
- await q.queryHandle.stopTask(taskId);
357
- } else {
358
- log.warn(`[bloby-agent] Cannot stop task ${taskId} — no active query for ${conversationId}`);
359
- }
360
- }