@fonz/tgcc 0.2.0 → 0.4.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/dist/streaming.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { readFileSync, watchFile, unwatchFile, existsSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { markdownToTelegramHtml } from './telegram-html.js';
1
5
  // ── HTML safety & conversion ──
2
6
  /** Escape characters that are special in Telegram HTML. */
3
7
  export function escapeHtml(text) {
@@ -7,43 +11,11 @@ export function escapeHtml(text) {
7
11
  .replace(/>/g, '>');
8
12
  }
9
13
  /**
10
- * Convert markdown-ish text to Telegram-safe HTML.
11
- * Handles code blocks, inline code, bold, italic, strikethrough, links.
12
- * Falls back to HTML-escaped plain text for anything it can't convert.
14
+ * Convert markdown text to Telegram-safe HTML using the marked library.
15
+ * Replaces the old hand-rolled implementation with a proper markdown parser.
13
16
  */
14
17
  export function markdownToHtml(text) {
15
- // First, extract code blocks to protect them from other conversions
16
- const codeBlocks = [];
17
- let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
18
- const idx = codeBlocks.length;
19
- const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
20
- codeBlocks.push(`<pre><code${langAttr}>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
21
- return `\x00CODEBLOCK${idx}\x00`;
22
- });
23
- // Extract inline code
24
- const inlineCodes = [];
25
- result = result.replace(/`([^`\n]+)`/g, (_match, code) => {
26
- const idx = inlineCodes.length;
27
- inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
28
- return `\x00INLINE${idx}\x00`;
29
- });
30
- // Escape HTML in remaining text
31
- result = escapeHtml(result);
32
- // Convert markdown formatting
33
- // Bold: **text** or __text__
34
- result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
35
- result = result.replace(/__(.+?)__/g, '<b>$1</b>');
36
- // Italic: *text* or _text_ (but not inside words for underscore)
37
- result = result.replace(/(?<!\w)\*([^*\n]+?)\*(?!\w)/g, '<i>$1</i>');
38
- result = result.replace(/(?<!\w)_([^_\n]+?)_(?!\w)/g, '<i>$1</i>');
39
- // Strikethrough: ~~text~~
40
- result = result.replace(/~~(.+?)~~/g, '<s>$1</s>');
41
- // Links: [text](url)
42
- result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
43
- // Restore code blocks and inline code
44
- result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_match, idx) => codeBlocks[Number(idx)]);
45
- result = result.replace(/\x00INLINE(\d+)\x00/g, (_match, idx) => inlineCodes[Number(idx)]);
46
- return result;
18
+ return markdownToTelegramHtml(text);
47
19
  }
48
20
  /**
49
21
  * Make text safe for Telegram HTML parse mode during streaming.
@@ -91,8 +63,7 @@ export class StreamAccumulator {
91
63
  async handleEvent(event) {
92
64
  switch (event.type) {
93
65
  case 'message_start':
94
- // New assistant turn — reset buffer but keep tgMessageId to edit same message
95
- this.softReset();
66
+ // Bridge handles reset decision - no automatic reset here
96
67
  break;
97
68
  case 'content_block_start':
98
69
  await this.onContentBlockStart(event);
@@ -170,6 +141,8 @@ export class StreamAccumulator {
170
141
  }
171
142
  async _doSendOrEdit(text, rawHtml = false) {
172
143
  const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
144
+ // Update timing BEFORE API call to prevent races
145
+ this.lastEditTime = Date.now();
173
146
  try {
174
147
  if (!this.tgMessageId) {
175
148
  this.tgMessageId = await this.sender.sendMessage(this.chatId, safeText, 'HTML');
@@ -178,7 +151,6 @@ export class StreamAccumulator {
178
151
  else {
179
152
  await this.sender.editMessage(this.chatId, this.tgMessageId, safeText, 'HTML');
180
153
  }
181
- this.lastEditTime = Date.now();
182
154
  }
183
155
  catch (err) {
184
156
  // Handle TG rate limit (429)
@@ -233,32 +205,35 @@ export class StreamAccumulator {
233
205
  }, delay);
234
206
  }
235
207
  }
236
- /** Build the full message text including thinking blockquote prefix and usage footer */
208
+ /** Build the full message text including thinking blockquote prefix and usage footer.
209
+ * Returns { text, hasHtmlSuffix } — caller must pass rawHtml=true when hasHtmlSuffix is set
210
+ * because the footer contains pre-formatted HTML (<i> tags).
211
+ */
237
212
  buildFullText(includeSuffix = false) {
238
213
  let text = '';
239
214
  if (this.thinkingBuffer) {
240
- // Truncate thinking to 1024 chars max for the expandable blockquote
241
215
  const thinkingPreview = this.thinkingBuffer.length > 1024
242
216
  ? this.thinkingBuffer.slice(0, 1024) + '…'
243
217
  : this.thinkingBuffer;
244
218
  text += `<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>\n`;
245
219
  }
246
- text += this.buffer;
220
+ // Convert markdown buffer to HTML-safe text
221
+ text += makeHtmlSafe(this.buffer);
247
222
  if (includeSuffix && this.turnUsage) {
248
223
  text += '\n' + formatUsageFooter(this.turnUsage);
249
224
  }
250
- return text;
225
+ return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
251
226
  }
252
227
  async doEdit() {
253
228
  if (!this.buffer)
254
229
  return;
255
- const fullText = this.buildFullText();
230
+ const { text, hasHtmlSuffix } = this.buildFullText();
256
231
  // Check if we need to split
257
- if (fullText.length > this.splitThreshold) {
232
+ if (text.length > this.splitThreshold) {
258
233
  await this.splitMessage();
259
234
  return;
260
235
  }
261
- await this.sendOrEdit(fullText);
236
+ await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
262
237
  }
263
238
  async splitMessage() {
264
239
  // Find a good split point near the threshold
@@ -266,12 +241,12 @@ export class StreamAccumulator {
266
241
  const firstPart = this.buffer.slice(0, splitAt);
267
242
  const remainder = this.buffer.slice(splitAt);
268
243
  // Finalize current message with first part
269
- await this.sendOrEdit(firstPart);
244
+ await this.sendOrEdit(makeHtmlSafe(firstPart), true);
270
245
  // Start a new message for remainder
271
246
  this.tgMessageId = null;
272
247
  this.buffer = remainder;
273
248
  if (this.buffer) {
274
- await this.sendOrEdit(this.buffer);
249
+ await this.sendOrEdit(makeHtmlSafe(this.buffer), true);
275
250
  }
276
251
  }
277
252
  async finalize() {
@@ -283,8 +258,8 @@ export class StreamAccumulator {
283
258
  }
284
259
  if (this.buffer) {
285
260
  // Final edit with complete text including thinking blockquote and usage footer
286
- const fullText = this.buildFullText(true);
287
- await this.sendOrEdit(fullText);
261
+ const { text } = this.buildFullText(true);
262
+ await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
288
263
  }
289
264
  else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
290
265
  // Only thinking happened, no text — show thinking as expandable blockquote
@@ -294,6 +269,12 @@ export class StreamAccumulator {
294
269
  await this.sendOrEdit(`<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>`, true);
295
270
  }
296
271
  }
272
+ clearEditTimer() {
273
+ if (this.editTimer) {
274
+ clearTimeout(this.editTimer);
275
+ this.editTimer = null;
276
+ }
277
+ }
297
278
  /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
298
279
  softReset() {
299
280
  this.buffer = '';
@@ -305,10 +286,7 @@ export class StreamAccumulator {
305
286
  this.toolIndicators = [];
306
287
  this.finished = false;
307
288
  this.turnUsage = null;
308
- if (this.editTimer) {
309
- clearTimeout(this.editTimer);
310
- this.editTimer = null;
311
- }
289
+ this.clearEditTimer(); // Ensure cleanup
312
290
  }
313
291
  /** Full reset: also clears tgMessageId (next send creates a new message) */
314
292
  reset() {
@@ -356,25 +334,145 @@ export function formatUsageFooter(usage) {
356
334
  return `<i>${parts.join(' · ')}</i>`;
357
335
  }
358
336
  // ── Sub-agent detection patterns ──
359
- const SUB_AGENT_TOOL_PATTERNS = [/agent/i, /dispatch/i, /^task$/i];
337
+ const CC_SUB_AGENT_TOOLS = new Set([
338
+ 'Task', // Primary CC spawning tool
339
+ 'dispatch_agent', // Legacy/alternative tool
340
+ 'create_agent', // Test compatibility
341
+ 'AgentRunner' // Test compatibility
342
+ ]);
360
343
  export function isSubAgentTool(toolName) {
361
- return SUB_AGENT_TOOL_PATTERNS.some(p => p.test(toolName));
344
+ return CC_SUB_AGENT_TOOLS.has(toolName);
345
+ }
346
+ /** Extract a human-readable summary from partial/complete JSON tool input.
347
+ * Looks for prompt, task, command, description fields — returns the first found, truncated.
348
+ */
349
+ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
350
+ try {
351
+ const parsed = JSON.parse(jsonInput);
352
+ const value = parsed.prompt || parsed.task || parsed.command || parsed.description || parsed.message || '';
353
+ if (typeof value === 'string' && value.length > 0) {
354
+ return value.length > maxLen ? value.slice(0, maxLen) + '…' : value;
355
+ }
356
+ }
357
+ catch {
358
+ // Partial JSON — try regex extraction for common patterns
359
+ for (const key of ['prompt', 'task', 'command', 'description', 'message']) {
360
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'i');
361
+ const m = jsonInput.match(re);
362
+ if (m?.[1]) {
363
+ const val = m[1].replace(/\\n/g, ' ').replace(/\\"/g, '"');
364
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
365
+ }
366
+ }
367
+ }
368
+ return '';
369
+ }
370
+ /**
371
+ * Extract a human-readable label for a sub-agent from its JSON tool input.
372
+ * Uses CC's Task tool structured fields: name, description, subagent_type, team_name.
373
+ * No regex guessing — purely structural JSON field extraction.
374
+ */
375
+ export function extractAgentLabel(jsonInput) {
376
+ // Priority order of CC Task tool fields
377
+ const labelFields = ['name', 'description', 'subagent_type', 'team_name'];
378
+ const summaryField = 'prompt'; // last resort — first line of prompt
379
+ try {
380
+ const parsed = JSON.parse(jsonInput);
381
+ for (const key of labelFields) {
382
+ const val = parsed[key];
383
+ if (typeof val === 'string' && val.trim()) {
384
+ return val.trim().slice(0, 80);
385
+ }
386
+ }
387
+ if (typeof parsed[summaryField] === 'string' && parsed[summaryField].trim()) {
388
+ const firstLine = parsed[summaryField].trim().split('\n')[0];
389
+ return firstLine.length > 60 ? firstLine.slice(0, 60) + '…' : firstLine;
390
+ }
391
+ return '';
392
+ }
393
+ catch {
394
+ // JSON incomplete during streaming — extract first complete field value
395
+ return extractFieldFromPartialJson(jsonInput, labelFields) ?? '';
396
+ }
397
+ }
398
+ /** Extract the first complete string value for any of the given keys from partial JSON. */
399
+ function extractFieldFromPartialJson(input, keys) {
400
+ for (const key of keys) {
401
+ const idx = input.indexOf(`"${key}"`);
402
+ if (idx === -1)
403
+ continue;
404
+ const afterKey = input.slice(idx + key.length + 2);
405
+ const colonIdx = afterKey.indexOf(':');
406
+ if (colonIdx === -1)
407
+ continue;
408
+ const afterColon = afterKey.slice(colonIdx + 1).trimStart();
409
+ if (!afterColon.startsWith('"'))
410
+ continue;
411
+ // Walk the string handling escapes
412
+ let i = 1, value = '';
413
+ while (i < afterColon.length) {
414
+ if (afterColon[i] === '\\' && i + 1 < afterColon.length) {
415
+ value += afterColon[i + 1] === 'n' ? ' ' : afterColon[i + 1];
416
+ i += 2;
417
+ }
418
+ else if (afterColon[i] === '"') {
419
+ if (value.trim())
420
+ return value.trim().slice(0, 80);
421
+ break;
422
+ }
423
+ else {
424
+ value += afterColon[i];
425
+ i++;
426
+ }
427
+ }
428
+ }
429
+ return null;
362
430
  }
363
431
  export class SubAgentTracker {
364
432
  chatId;
365
433
  sender;
366
- getMainMessageId;
367
434
  agents = new Map(); // toolUseId → info
368
435
  blockToAgent = new Map(); // blockIndex → toolUseId
369
436
  sendQueue = Promise.resolve();
437
+ teamName = null;
438
+ mailboxPath = null;
439
+ mailboxWatching = false;
440
+ lastMailboxCount = 0;
441
+ onAllReported = null;
442
+ hasPendingFollowUp = false;
370
443
  constructor(options) {
371
444
  this.chatId = options.chatId;
372
445
  this.sender = options.sender;
373
- this.getMainMessageId = options.getMainMessageId;
374
446
  }
375
447
  get activeAgents() {
376
448
  return [...this.agents.values()];
377
449
  }
450
+ /** Returns true if any sub-agents were tracked in this turn (including completed ones) */
451
+ get hadSubAgents() {
452
+ return this.agents.size > 0;
453
+ }
454
+ /** Returns true if any sub-agents are in dispatched state (spawned but no result yet) */
455
+ get hasDispatchedAgents() {
456
+ return [...this.agents.values()].some(a => a.status === 'dispatched');
457
+ }
458
+ /** Mark all dispatched agents as completed — used when CC reports results
459
+ * in its main text response rather than via tool_result events.
460
+ */
461
+ markDispatchedAsReportedInMain() {
462
+ for (const [, info] of this.agents) {
463
+ if (info.status !== 'dispatched' || !info.tgMessageId)
464
+ continue;
465
+ info.status = 'completed';
466
+ const label = info.label || info.toolName;
467
+ const text = `✅ ${escapeHtml(label)} — see main message`;
468
+ this.sendQueue = this.sendQueue.then(async () => {
469
+ try {
470
+ await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
471
+ }
472
+ catch { /* ignore */ }
473
+ });
474
+ }
475
+ }
378
476
  async handleEvent(event) {
379
477
  switch (event.type) {
380
478
  case 'content_block_start':
@@ -390,11 +488,80 @@ export class SubAgentTracker {
390
488
  case 'content_block_stop':
391
489
  await this.onBlockStop(event);
392
490
  break;
393
- case 'message_start':
394
- // New turn reset tracker
395
- this.reset();
396
- break;
491
+ // NOTE: message_start reset is handled by the bridge (not here)
492
+ // so it can check hadSubAgents before clearing state
493
+ }
494
+ }
495
+ /** Handle a tool_result event — marks the sub-agent as completed with collapsible result */
496
+ /** Set agent metadata from structured tool_use_result */
497
+ setAgentMetadata(toolUseId, meta) {
498
+ const info = this.agents.get(toolUseId);
499
+ if (!info)
500
+ return;
501
+ if (meta.agentName)
502
+ info.agentName = meta.agentName;
503
+ }
504
+ /** Mark an agent as completed externally (e.g. from bridge follow-up) */
505
+ markCompleted(toolUseId, _reason) {
506
+ const info = this.agents.get(toolUseId);
507
+ if (!info || info.status === 'completed')
508
+ return;
509
+ info.status = 'completed';
510
+ // Check if all agents are done
511
+ const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
512
+ if (allDone && this.onAllReported) {
513
+ this.onAllReported();
514
+ this.stopMailboxWatch();
515
+ }
516
+ }
517
+ async handleToolResult(toolUseId, result) {
518
+ const info = this.agents.get(toolUseId);
519
+ if (!info || !info.tgMessageId)
520
+ return;
521
+ // Detect background agent spawn confirmations — keep as dispatched, don't mark completed
522
+ // Spawn confirmations contain "agent_id:" and "Spawned" patterns
523
+ const isSpawnConfirmation = /agent_id:\s*\S+@\S+/.test(result) || /[Ss]pawned\s+successfully/i.test(result);
524
+ if (isSpawnConfirmation) {
525
+ // Extract agent name from spawn confirmation for mailbox matching
526
+ const nameMatch = result.match(/name:\s*(\S+)/);
527
+ if (nameMatch && !info.agentName)
528
+ info.agentName = nameMatch[1];
529
+ const agentIdMatch = result.match(/agent_id:\s*(\S+)@/);
530
+ if (agentIdMatch && !info.agentName)
531
+ info.agentName = agentIdMatch[1];
532
+ // Mark as dispatched — this enables mailbox watching and prevents idle timeout
533
+ info.status = 'dispatched';
534
+ info.dispatchedAt = Date.now();
535
+ const label = info.label || info.toolName;
536
+ const text = `🤖 ${escapeHtml(label)} — Spawned, waiting for results…`;
537
+ this.sendQueue = this.sendQueue.then(async () => {
538
+ try {
539
+ await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
540
+ }
541
+ catch { /* ignore */ }
542
+ });
543
+ await this.sendQueue;
544
+ return;
397
545
  }
546
+ // Skip if already completed (e.g. via mailbox)
547
+ if (info.status === 'completed')
548
+ return;
549
+ info.status = 'completed';
550
+ const label = info.label || info.toolName;
551
+ // Truncate result for blockquote (TG message limit ~4096 chars)
552
+ const maxResultLen = 3500;
553
+ const resultText = result.length > maxResultLen ? result.slice(0, maxResultLen) + '…' : result;
554
+ // Use expandable blockquote — collapsed shows "✅ label" + first line, tap to expand
555
+ const text = `<blockquote expandable>✅ ${escapeHtml(label)}\n${escapeHtml(resultText)}</blockquote>`;
556
+ this.sendQueue = this.sendQueue.then(async () => {
557
+ try {
558
+ await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
559
+ }
560
+ catch {
561
+ // Ignore edit failures
562
+ }
563
+ });
564
+ await this.sendQueue;
398
565
  }
399
566
  async onBlockStart(event) {
400
567
  if (event.content_block.type !== 'tool_use')
@@ -408,24 +575,24 @@ export class SubAgentTracker {
408
575
  blockIndex: event.index,
409
576
  tgMessageId: null,
410
577
  status: 'running',
578
+ label: '',
579
+ agentName: '',
411
580
  inputPreview: '',
581
+ dispatchedAt: null,
412
582
  };
413
583
  this.agents.set(block.id, info);
414
584
  this.blockToAgent.set(event.index, block.id);
415
- // Send reply message
416
- const mainMsgId = this.getMainMessageId();
417
- if (mainMsgId) {
418
- this.sendQueue = this.sendQueue.then(async () => {
419
- try {
420
- const msgId = await this.sender.replyToMessage(this.chatId, `🔄 Sub-agent spawned: <code>${escapeHtml(block.name)}</code>`, mainMsgId, 'HTML');
421
- info.tgMessageId = msgId;
422
- }
423
- catch (err) {
424
- // Silently ignore — main stream continues regardless
425
- }
426
- });
427
- await this.sendQueue;
428
- }
585
+ // Send standalone message (no reply_to — cleaner in private chat)
586
+ this.sendQueue = this.sendQueue.then(async () => {
587
+ try {
588
+ const msgId = await this.sender.sendMessage(this.chatId, '🤖 Starting sub-agent…', 'HTML');
589
+ info.tgMessageId = msgId;
590
+ }
591
+ catch (err) {
592
+ // Silently ignore — main stream continues regardless
593
+ }
594
+ });
595
+ await this.sendQueue;
429
596
  }
430
597
  async onInputDelta(event) {
431
598
  const toolUseId = this.blockToAgent.get(event.index);
@@ -435,21 +602,39 @@ export class SubAgentTracker {
435
602
  if (!info || !info.tgMessageId)
436
603
  return;
437
604
  info.inputPreview += event.delta.partial_json;
438
- // Throttle: only update when we have a reasonable chunk (every 200 chars)
439
- if (info.inputPreview.length % 200 > 50)
440
- return;
441
- const preview = info.inputPreview.length > 300
442
- ? info.inputPreview.slice(0, 300) + '…'
443
- : info.inputPreview;
444
- this.sendQueue = this.sendQueue.then(async () => {
605
+ // Extract agent name from input JSON (used for mailbox matching)
606
+ if (!info.agentName) {
445
607
  try {
446
- await this.sender.editMessage(this.chatId, info.tgMessageId, `🔄 Sub-agent: <code>${escapeHtml(info.toolName)}</code>\n\n<pre>${escapeHtml(preview)}</pre>`, 'HTML');
608
+ const parsed = JSON.parse(info.inputPreview);
609
+ if (typeof parsed.name === 'string' && parsed.name.trim()) {
610
+ info.agentName = parsed.name.trim();
611
+ }
447
612
  }
448
613
  catch {
449
- // Ignore edit failures (rate limits, not modified, etc.)
614
+ // Partial JSON try extracting name field
615
+ const nameMatch = info.inputPreview.match(/"name"\s*:\s*"([^"]+)"/);
616
+ if (nameMatch)
617
+ info.agentName = nameMatch[1];
450
618
  }
451
- });
452
- await this.sendQueue;
619
+ }
620
+ // Try to extract an agent label
621
+ if (!info.label) {
622
+ const label = extractAgentLabel(info.inputPreview);
623
+ if (label) {
624
+ info.label = label;
625
+ // Once we have a label, update the message to show it
626
+ const displayLabel = info.label;
627
+ this.sendQueue = this.sendQueue.then(async () => {
628
+ try {
629
+ await this.sender.editMessage(this.chatId, info.tgMessageId, `🤖 ${escapeHtml(displayLabel)} — Working…`, 'HTML');
630
+ }
631
+ catch {
632
+ // Ignore edit failures
633
+ }
634
+ });
635
+ await this.sendQueue;
636
+ }
637
+ }
453
638
  }
454
639
  async onBlockStop(event) {
455
640
  const toolUseId = this.blockToAgent.get(event.index);
@@ -458,25 +643,17 @@ export class SubAgentTracker {
458
643
  const info = this.agents.get(toolUseId);
459
644
  if (!info || !info.tgMessageId)
460
645
  return;
461
- info.status = 'completed';
462
- // Extract a summary from the input preview (first ~100 chars)
463
- let summary = '';
464
- try {
465
- const parsed = JSON.parse(info.inputPreview);
466
- // Common patterns: { prompt: "..." }, { task: "..." }, { command: "..." }
467
- summary = parsed.prompt || parsed.task || parsed.command || parsed.description || '';
468
- if (typeof summary === 'string' && summary.length > 100) {
469
- summary = summary.slice(0, 100) + '…';
470
- }
471
- }
472
- catch {
473
- summary = info.inputPreview.slice(0, 100);
474
- if (info.inputPreview.length > 100)
475
- summary += '…';
646
+ // content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
647
+ info.status = 'dispatched';
648
+ info.dispatchedAt = Date.now();
649
+ // Final chance to extract label from complete input
650
+ if (!info.label) {
651
+ const label = extractAgentLabel(info.inputPreview);
652
+ if (label)
653
+ info.label = label;
476
654
  }
477
- const text = summary
478
- ? `✅ Sub-agent completed: <code>${escapeHtml(info.toolName)}</code>\n<i>${escapeHtml(summary)}</i>`
479
- : `✅ Sub-agent completed: <code>${escapeHtml(info.toolName)}</code>`;
655
+ const displayLabel = info.label || info.toolName;
656
+ const text = `🤖 ${escapeHtml(displayLabel)} — Working…`;
480
657
  this.sendQueue = this.sendQueue.then(async () => {
481
658
  try {
482
659
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
@@ -486,11 +663,137 @@ export class SubAgentTracker {
486
663
  }
487
664
  });
488
665
  await this.sendQueue;
666
+ // Start elapsed timer — update every 15s to show progress
667
+ }
668
+ /** Start a periodic timer that edits the message with elapsed time */
669
+ /** Set callback invoked when ALL dispatched sub-agents have mailbox results. */
670
+ setOnAllReported(cb) {
671
+ this.onAllReported = cb;
672
+ }
673
+ /** Set the CC team name (extracted from spawn confirmation tool_result). */
674
+ setTeamName(name) {
675
+ this.teamName = name;
676
+ this.mailboxPath = join(homedir(), '.claude', 'teams', name, 'inboxes', 'team-lead.json');
677
+ }
678
+ get currentTeamName() { return this.teamName; }
679
+ get isMailboxWatching() { return this.mailboxWatching; }
680
+ /** Start watching the mailbox file for sub-agent results. */
681
+ startMailboxWatch() {
682
+ if (this.mailboxWatching) {
683
+ return;
684
+ }
685
+ if (!this.mailboxPath) {
686
+ return;
687
+ }
688
+ this.mailboxWatching = true;
689
+ // Ensure directory exists so watchFile doesn't error
690
+ const dir = dirname(this.mailboxPath);
691
+ if (!existsSync(dir)) {
692
+ mkdirSync(dir, { recursive: true });
693
+ }
694
+ // Start from 0 — process all messages including pre-existing ones
695
+ // Background agents may finish before the watcher starts
696
+ this.lastMailboxCount = 0;
697
+ // Process immediately in case messages arrived before watching
698
+ this.processMailbox();
699
+ watchFile(this.mailboxPath, { interval: 2000 }, () => {
700
+ this.processMailbox();
701
+ });
702
+ }
703
+ /** Stop watching the mailbox file. */
704
+ stopMailboxWatch() {
705
+ if (!this.mailboxWatching || !this.mailboxPath)
706
+ return;
707
+ try {
708
+ unwatchFile(this.mailboxPath);
709
+ }
710
+ catch { /* ignore */ }
711
+ this.mailboxWatching = false;
712
+ }
713
+ /** Read and parse the mailbox file. Returns [] on any error. */
714
+ readMailboxMessages() {
715
+ if (!this.mailboxPath || !existsSync(this.mailboxPath))
716
+ return [];
717
+ try {
718
+ const raw = readFileSync(this.mailboxPath, 'utf-8');
719
+ const parsed = JSON.parse(raw);
720
+ return Array.isArray(parsed) ? parsed : [];
721
+ }
722
+ catch {
723
+ return [];
724
+ }
725
+ }
726
+ /** Process new mailbox messages and update sub-agent TG messages. */
727
+ processMailbox() {
728
+ const messages = this.readMailboxMessages();
729
+ if (messages.length <= this.lastMailboxCount)
730
+ return;
731
+ const newMessages = messages.slice(this.lastMailboxCount);
732
+ this.lastMailboxCount = messages.length;
733
+ for (const msg of newMessages) {
734
+ // Don't filter by msg.read — CC may read its mailbox before our 2s poll fires
735
+ // We track by message count (lastMailboxCount) to avoid duplicates
736
+ // Skip idle notifications (JSON objects, not real results)
737
+ if (msg.text.startsWith('{'))
738
+ continue;
739
+ // Match msg.from to a tracked sub-agent
740
+ const matched = this.findAgentByFrom(msg.from);
741
+ if (!matched) {
742
+ console.error(`[MAILBOX] No match for from="${msg.from}". Agents: ${[...this.agents.values()].map(a => `${a.agentName}/${a.label}/${a.status}`).join(', ')}`);
743
+ continue;
744
+ }
745
+ matched.status = 'completed';
746
+ if (!matched.tgMessageId)
747
+ continue;
748
+ // React with ✅ instead of editing — avoids race conditions
749
+ const msgId = matched.tgMessageId;
750
+ const emoji = msg.color === 'red' ? '👎' : '👍';
751
+ this.sendQueue = this.sendQueue.then(async () => {
752
+ try {
753
+ await this.sender.setReaction?.(this.chatId, msgId, emoji);
754
+ }
755
+ catch { /* ignore — reaction might not be supported */ }
756
+ });
757
+ }
758
+ // Check if ALL dispatched agents are now completed
759
+ if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
760
+ // All done — invoke callback
761
+ const cb = this.onAllReported;
762
+ // Defer slightly to let edits flush
763
+ setTimeout(() => cb(), 500);
764
+ }
765
+ }
766
+ /** Find a tracked sub-agent whose label matches the mailbox message's `from` field. */
767
+ findAgentByFrom(from) {
768
+ const fromLower = from.toLowerCase();
769
+ for (const info of this.agents.values()) {
770
+ if (info.status !== 'dispatched')
771
+ continue;
772
+ // Primary match: agentName (CC's internal agent name, used as mailbox 'from')
773
+ if (info.agentName && info.agentName.toLowerCase() === fromLower) {
774
+ return info;
775
+ }
776
+ // Fallback: fuzzy label match
777
+ const label = (info.label || info.toolName).toLowerCase();
778
+ if (label === fromLower || label.includes(fromLower) || fromLower.includes(label)) {
779
+ return info;
780
+ }
781
+ }
782
+ return null;
489
783
  }
490
784
  reset() {
785
+ // Stop mailbox watching
786
+ this.stopMailboxWatch();
787
+ // Clear all elapsed timers before resetting
788
+ for (const info of this.agents.values()) {
789
+ }
491
790
  this.agents.clear();
492
791
  this.blockToAgent.clear();
493
792
  this.sendQueue = Promise.resolve();
793
+ this.teamName = null;
794
+ this.mailboxPath = null;
795
+ this.lastMailboxCount = 0;
796
+ this.onAllReported = null;
494
797
  }
495
798
  }
496
799
  // ── Utility: split a completed text into TG-sized chunks ──