bloby-bot 0.20.7 → 0.21.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.20.7",
3
+ "version": "0.21.0",
4
4
  "releaseNotes": [
5
5
  "1. react router implemented",
6
6
  "2. new workspace design",
@@ -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
- }
@@ -20,7 +20,7 @@ import path from 'path';
20
20
  import { loadConfig } from '../../shared/config.js';
21
21
  import { WORKSPACE_DIR } from '../../shared/paths.js';
22
22
  import { log } from '../../shared/logger.js';
23
- import { startBlobyAgentQuery, type RecentMessage } from '../bloby-agent.js';
23
+ import { startBlobyAgentQuery, startConversation, pushMessage, hasConversation, type RecentMessage } from '../bloby-agent.js';
24
24
  import { WhatsAppChannel } from './whatsapp.js';
25
25
  import type { ChannelConfig, ChannelProvider, ChannelStatus, ChannelType, InboundMessage, InboundMessageAttachment, SenderRole } from './types.js';
26
26
  import type { AgentAttachment } from '../bloby-agent.js';
@@ -392,14 +392,14 @@ export class ChannelManager {
392
392
  // Show "typing..." while the agent processes
393
393
  this.startTyping(msg.channel, msg.rawSender);
394
394
 
395
- // Track text chunks for WhatsApp — send intermediate chunks when agent pauses for tool use
395
+ // Track text chunks for WhatsApp — lives for the conversation lifetime
396
396
  let waChunkBuf = '';
397
397
 
398
- startBlobyAgentQuery(
399
- convId,
400
- channelContext + msg.text,
401
- model,
402
- (type, eventData) => {
398
+ // Start a live conversation if one doesn't exist (shared with chat UI)
399
+ if (!hasConversation(convId)) {
400
+ log.info(`[channels] Starting live conversation for admin: ${convId}`);
401
+
402
+ await startConversation(convId, model, (type, eventData) => {
403
403
  // Accumulate text tokens
404
404
  if (type === 'bot:token' && eventData.token) {
405
405
  waChunkBuf += eventData.token;
@@ -414,7 +414,7 @@ export class ChannelManager {
414
414
  }
415
415
 
416
416
  if (type === 'bot:response' && eventData.content) {
417
- // Send remaining text after the last tool use (or the full response if no tools were used)
417
+ // Send remaining text
418
418
  const remaining = waChunkBuf.trim();
419
419
  if (remaining) {
420
420
  this.sendMessage(msg.channel, msg.rawSender, remaining).catch((err) => {
@@ -431,22 +431,22 @@ export class ChannelManager {
431
431
  }).catch(() => {});
432
432
  }
433
433
 
434
- // Mirror streaming + task events to chat clients
435
- if (type === 'bot:token' || type === 'bot:response' || type === 'bot:typing' || type === 'bot:tool' || type.startsWith('bot:task-')) {
436
- broadcastBloby(type, eventData);
437
- }
438
-
439
- if (type === 'bot:done' && eventData.usedFileTools) {
434
+ // Handle turn completion restart backend if needed
435
+ if (type === 'bot:turn-complete' && eventData.usedFileTools) {
440
436
  this.opts.restartBackend();
441
437
  }
442
- },
443
- agentAttachments,
444
- undefined,
445
- { botName, humanName },
446
- recentMessages,
447
- undefined, // no supportPrompt
448
- 8, // maxTurns: orchestrator mode
449
- );
438
+
439
+ // Don't forward internal events to chat clients
440
+ if (type === 'bot:turn-complete' || type === 'bot:conversation-ended') return;
441
+
442
+ // Mirror streaming + task events to chat clients
443
+ broadcastBloby(type, eventData);
444
+ }, { botName, humanName }, recentMessages);
445
+ }
446
+
447
+ // Push the message into the live conversation
448
+ const channelContent = channelContext + msg.text;
449
+ pushMessage(convId, channelContent, agentAttachments);
450
450
  }
451
451
 
452
452
  /** Handle message from a customer — runs support agent in parallel with conversation context */
@@ -13,7 +13,12 @@ import { createWorkerApp } from '../worker/index.js';
13
13
  import { closeDb, getSession, getSetting } from '../worker/db.js';
14
14
  import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, isBackendStopping, resetBackendRestarts } from './backend.js';
15
15
  import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
16
- import { startBlobyAgentQuery, stopBlobyAgentQuery, stopSubAgentTask, type RecentMessage } from './bloby-agent.js';
16
+ import {
17
+ startConversation, pushMessage, hasConversation, endConversation,
18
+ isConversationBusy, stopSubAgentTask,
19
+ startBlobyAgentQuery, stopBlobyAgentQuery,
20
+ type RecentMessage,
21
+ } from './bloby-agent.js';
17
22
  import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
18
23
  import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
19
24
  import { startScheduler, stopScheduler } from './scheduler.js';
@@ -1127,84 +1132,101 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
1127
1132
  }
1128
1133
  } catch {}
1129
1134
 
1130
- // Mirror chat responses to WhatsApp self-chat (if connected)
1131
- const waStatus = channelManager.getStatus('whatsapp');
1132
- const waMirrorJid = waStatus?.connected ? waStatus.info?.phoneNumber : null;
1133
- let waChunkBuf = '';
1134
-
1135
- // Start orchestrator query (maxTurns: 8 — quick tasks direct, coding delegated)
1136
1135
  log.info(`[orchestrator] ──── USER MESSAGE ────`);
1137
1136
  log.info(`[orchestrator] Content: "${content.slice(0, 100)}..."`);
1138
- log.info(`[orchestrator] Model: ${freshConfig.ai.model}`);
1139
1137
  log.info(`[orchestrator] Conv: ${convId}`);
1140
- log.info(`[orchestrator] MaxTurns: 5 (orchestrator mode)`);
1141
- agentQueryActive = true;
1142
- currentStreamConvId = convId;
1143
- currentStreamBuffer = '';
1144
- startBlobyAgentQuery(convId, content, freshConfig.ai.model, (type, eventData) => {
1145
- // Track stream buffer for reconnecting clients
1146
- if (type === 'bot:token' && eventData.token) {
1147
- currentStreamBuffer += eventData.token;
1148
- if (waMirrorJid) waChunkBuf += eventData.token;
1149
- }
1138
+ log.info(`[orchestrator] Live conversation exists: ${hasConversation(convId)}`);
1150
1139
 
1151
- // WhatsApp mirror: send intermediate chunk when agent pauses for tool use
1152
- if (type === 'bot:tool' && waMirrorJid && waChunkBuf.trim()) {
1153
- channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
1154
- waChunkBuf = '';
1155
- }
1140
+ // Start a live conversation if one doesn't exist
1141
+ if (!hasConversation(convId)) {
1142
+ log.info(`[orchestrator] Starting new live conversation...`);
1156
1143
 
1157
- // Intercept bot:doneorchestrator turn finished
1158
- if (type === 'bot:done') {
1159
- log.info(`[orchestrator] ──── TURN COMPLETE ────`);
1160
- log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
1161
- agentQueryActive = false;
1162
- currentStreamConvId = null;
1163
- currentStreamBuffer = '';
1164
- // Restart if agent used file tools OR file watcher detected changes during the turn
1165
- if (eventData.usedFileTools || pendingBackendRestart) {
1166
- console.log('[supervisor] Agent turn ended — restarting backend');
1167
- pendingBackendRestart = false;
1168
- if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
1169
- resetBackendRestarts();
1170
- stopBackend().then(() => spawnBackend(backendPort));
1171
- }
1172
- // Run deferred update if agent requested one
1173
- if (pendingUpdate) {
1174
- pendingUpdate = false;
1175
- runDeferredUpdate();
1144
+ // WhatsApp mirror state lives for the conversation lifetime
1145
+ let waChunkBuf = '';
1146
+
1147
+ await startConversation(convId, freshConfig.ai.model, (type, eventData) => {
1148
+ // Check WA mirror on each event (connection state may change)
1149
+ const waStatus = channelManager.getStatus('whatsapp');
1150
+ const waMirrorJid = waStatus?.connected ? waStatus.info?.phoneNumber : null;
1151
+
1152
+ // Track stream buffer for reconnecting clients
1153
+ if (type === 'bot:typing') {
1154
+ currentStreamConvId = convId;
1155
+ currentStreamBuffer = '';
1156
+ agentQueryActive = true;
1176
1157
  }
1177
- return; // don't forward bot:done to client
1178
- }
1179
1158
 
1180
- // Save assistant response to DB + clear stream state
1181
- if (type === 'bot:response') {
1182
- currentStreamBuffer = '';
1159
+ if (type === 'bot:token' && eventData.token) {
1160
+ currentStreamBuffer += eventData.token;
1161
+ if (waMirrorJid) waChunkBuf += eventData.token;
1162
+ }
1183
1163
 
1184
- // WhatsApp mirror: send remaining chunk
1185
- if (waMirrorJid && waChunkBuf.trim()) {
1164
+ // WhatsApp mirror: send intermediate chunk when agent pauses for tool use
1165
+ if (type === 'bot:tool' && waMirrorJid && waChunkBuf.trim()) {
1186
1166
  channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
1187
1167
  waChunkBuf = '';
1188
1168
  }
1189
1169
 
1190
- (async () => {
1191
- try {
1192
- await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1193
- role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1194
- });
1195
- } catch (err: any) {
1196
- log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1170
+ // Agent finished a turn — handle backend restart + state cleanup
1171
+ if (type === 'bot:turn-complete') {
1172
+ log.info(`[orchestrator] ──── TURN COMPLETE ────`);
1173
+ log.info(`[orchestrator] File tools used: ${eventData.usedFileTools}`);
1174
+ agentQueryActive = false;
1175
+ currentStreamConvId = null;
1176
+ currentStreamBuffer = '';
1177
+
1178
+ if (eventData.usedFileTools || pendingBackendRestart) {
1179
+ log.info('[orchestrator] Restarting backend (file tools used)');
1180
+ pendingBackendRestart = false;
1181
+ if (backendRestartTimer) { clearTimeout(backendRestartTimer); backendRestartTimer = null; }
1182
+ resetBackendRestarts();
1183
+ stopBackend().then(() => spawnBackend(backendPort));
1197
1184
  }
1198
- })();
1199
- }
1185
+ if (pendingUpdate) {
1186
+ pendingUpdate = false;
1187
+ runDeferredUpdate();
1188
+ }
1189
+ return; // don't forward to client
1190
+ }
1191
+
1192
+ // Conversation ended (query loop exited)
1193
+ if (type === 'bot:conversation-ended') {
1194
+ log.info(`[orchestrator] Conversation ended: ${convId}`);
1195
+ agentQueryActive = false;
1196
+ currentStreamConvId = null;
1197
+ currentStreamBuffer = '';
1198
+ return;
1199
+ }
1200
1200
 
1201
- // Stream all events to every connected client
1202
- // (includes bot:task-created, bot:task-progress, bot:task-done from SDK)
1203
- broadcastBloby(type, eventData);
1204
- }, data.attachments, savedFiles, { botName, humanName }, recentMessages,
1205
- undefined, // no supportPrompt
1206
- 8, // maxTurns: orchestrator mode
1207
- );
1201
+ // Save assistant response to DB
1202
+ if (type === 'bot:response') {
1203
+ currentStreamBuffer = '';
1204
+
1205
+ // WhatsApp mirror: send remaining chunk
1206
+ if (waMirrorJid && waChunkBuf.trim()) {
1207
+ channelManager.sendMessage('whatsapp', `${waMirrorJid}@s.whatsapp.net`, waChunkBuf.trim()).catch(() => {});
1208
+ waChunkBuf = '';
1209
+ }
1210
+
1211
+ (async () => {
1212
+ try {
1213
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1214
+ role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1215
+ });
1216
+ } catch (err: any) {
1217
+ log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1218
+ }
1219
+ })();
1220
+ }
1221
+
1222
+ // Stream all events to every connected client
1223
+ broadcastBloby(type, eventData);
1224
+ }, { botName, humanName }, recentMessages);
1225
+ }
1226
+
1227
+ // Push the user message into the live conversation
1228
+ log.info(`[orchestrator] Pushing message into live conversation`);
1229
+ pushMessage(convId, content, data.attachments, savedFiles);
1208
1230
  })();
1209
1231
  return;
1210
1232
  }
@@ -1238,7 +1260,13 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
1238
1260
  }
1239
1261
 
1240
1262
  if (msg.type === 'user:stop') {
1241
- stopBlobyAgentQuery(convId);
1263
+ // End the live conversation (if any) or stop a one-shot query
1264
+ if (hasConversation(convId)) {
1265
+ log.info(`[orchestrator] user:stop — ending live conversation ${convId}`);
1266
+ endConversation(convId);
1267
+ } else {
1268
+ stopBlobyAgentQuery(convId);
1269
+ }
1242
1270
  return;
1243
1271
  }
1244
1272
 
@@ -1256,6 +1284,11 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
1256
1284
  if (msg.type === 'user:clear-context') {
1257
1285
  (async () => {
1258
1286
  try {
1287
+ // End the live conversation
1288
+ if (hasConversation(convId)) {
1289
+ log.info(`[orchestrator] clear-context — ending live conversation ${convId}`);
1290
+ endConversation(convId);
1291
+ }
1259
1292
  clientConvs.delete(ws);
1260
1293
  await workerApi('/api/context/clear', 'POST');
1261
1294
  } catch (err: any) {
@@ -1325,11 +1358,14 @@ ${!connected ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
1325
1358
  }
1326
1359
  });
1327
1360
 
1328
- // Track whether an agent query is active — file watcher defers to bot:done during turns
1361
+ // Track whether an agent is actively processing — file watcher defers restarts during active turns
1329
1362
  let agentQueryActive = false;
1330
1363
  let pendingBackendRestart = false; // Set when file watcher fires during agent turn
1331
1364
  let pendingUpdate = false; // Set when .update file is created during agent turn
1332
1365
 
1366
+ // Note: with live conversations, agentQueryActive is true while the agent processes a message
1367
+ // and false when it's idle (waiting for next message). The live conversation stays alive between messages.
1368
+
1333
1369
  // Run bloby update as a child process.
1334
1370
  // BLOBY_SELF_UPDATE=1 tells bin/cli.js to skip daemon stop/restart —
1335
1371
  // the supervisor exits after the update finishes, and systemd (Restart=on-failure)
@@ -221,7 +221,7 @@ You handle two kinds of work differently:
221
221
  - Complex research or data gathering
222
222
  - Any coding task that touches workspace source files (client/, backend/)
223
223
 
224
- For quick tasks, use your tools directly — Read, Write, Edit, Bash. You have 5 turns, plenty for a config edit or memory write.
224
+ For quick tasks, use your tools directly — Read, Write, Edit, Bash.
225
225
 
226
226
  For coding tasks, use the Agent tool. It runs in the background — you respond immediately while the work happens behind the scenes.
227
227