@fonz/tgcc 0.6.12 β†’ 0.6.14

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
@@ -34,6 +34,8 @@ export class StreamAccumulator {
34
34
  sender;
35
35
  editIntervalMs;
36
36
  splitThreshold;
37
+ logger;
38
+ onError;
37
39
  // State
38
40
  tgMessageId = null;
39
41
  buffer = '';
@@ -43,16 +45,21 @@ export class StreamAccumulator {
43
45
  lastEditTime = 0;
44
46
  editTimer = null;
45
47
  thinkingIndicatorShown = false;
46
- toolIndicators = [];
47
48
  messageIds = []; // all message IDs sent during this turn
48
49
  finished = false;
49
50
  sendQueue = Promise.resolve();
50
51
  turnUsage = null;
52
+ // Per-tool-use independent indicator messages (persists across resets)
53
+ toolMessages = new Map();
54
+ toolInputBuffers = new Map(); // tool block ID β†’ accumulated input JSON
55
+ currentToolBlockId = null;
51
56
  constructor(options) {
52
57
  this.chatId = options.chatId;
53
58
  this.sender = options.sender;
54
59
  this.editIntervalMs = options.editIntervalMs ?? 1000;
55
60
  this.splitThreshold = options.splitThreshold ?? 4000;
61
+ this.logger = options.logger;
62
+ this.onError = options.onError;
56
63
  }
57
64
  get allMessageIds() { return [...this.messageIds]; }
58
65
  /** Set usage stats for the current turn (called from bridge on result event) */
@@ -91,20 +98,23 @@ export class StreamAccumulator {
91
98
  if (blockType === 'thinking') {
92
99
  this.currentBlockType = 'thinking';
93
100
  if (!this.thinkingIndicatorShown && !this.buffer) {
94
- await this.sendOrEdit('<i>πŸ’­ Thinking...</i>', true);
101
+ await this.sendOrEdit('<blockquote>πŸ’­ Thinking...</blockquote>', true);
95
102
  this.thinkingIndicatorShown = true;
96
103
  }
97
104
  }
98
105
  else if (blockType === 'text') {
99
106
  this.currentBlockType = 'text';
100
- // Clear tool indicators when real text starts
101
- this.toolIndicators = [];
107
+ // Text always gets its own message β€” don't touch tool indicator messages
108
+ if (!this.tgMessageId) {
109
+ // Will create a new message on next sendOrEdit
110
+ }
102
111
  }
103
112
  else if (blockType === 'tool_use') {
104
113
  this.currentBlockType = 'tool_use';
105
- const name = event.content_block.name;
106
- this.toolIndicators.push(name);
107
- await this.showToolIndicator(name);
114
+ const block = event.content_block;
115
+ this.currentToolBlockId = block.id;
116
+ // Send an independent indicator message for this tool_use block
117
+ await this.sendToolIndicator(block.id, block.name);
108
118
  }
109
119
  else if (blockType === 'image') {
110
120
  this.currentBlockType = 'image';
@@ -130,22 +140,37 @@ export class StreamAccumulator {
130
140
  this.thinkingBuffer += delta.thinking;
131
141
  }
132
142
  }
143
+ else if (this.currentBlockType === 'tool_use' && 'delta' in event) {
144
+ const delta = event.delta;
145
+ if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentToolBlockId) {
146
+ const blockId = this.currentToolBlockId;
147
+ const prev = this.toolInputBuffers.get(blockId) ?? '';
148
+ this.toolInputBuffers.set(blockId, prev + delta.partial_json);
149
+ // Update indicator with input preview once we have enough
150
+ await this.updateToolIndicatorWithInput(blockId);
151
+ }
152
+ }
133
153
  else if (this.currentBlockType === 'image' && 'delta' in event) {
134
154
  const delta = event.delta;
135
155
  if (delta?.type === 'image_delta' && delta.data) {
136
156
  this.imageBase64Buffer += delta.data;
137
157
  }
138
158
  }
139
- // Ignore input_json_delta content
140
159
  }
141
160
  // ── TG message management ──
142
161
  /** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
143
162
  async sendOrEdit(text, rawHtml = false) {
144
- this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml));
163
+ this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml)).catch(err => {
164
+ this.logger?.error?.({ err }, 'sendOrEdit failed');
165
+ this.onError?.(err, 'Failed to send/edit message');
166
+ });
145
167
  return this.sendQueue;
146
168
  }
147
169
  async _doSendOrEdit(text, rawHtml = false) {
148
- const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
170
+ let safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
171
+ // Guard against HTML that has tags but no visible text content (e.g. "<b></b>")
172
+ if (!safeText.replace(/<[^>]*>/g, '').trim())
173
+ safeText = '...';
149
174
  // Update timing BEFORE API call to prevent races
150
175
  this.lastEditTime = Date.now();
151
176
  try {
@@ -158,17 +183,20 @@ export class StreamAccumulator {
158
183
  }
159
184
  }
160
185
  catch (err) {
161
- // Handle TG rate limit (429)
162
- if (err && typeof err === 'object' && 'error_code' in err && err.error_code === 429) {
186
+ const errorCode = err && typeof err === 'object' && 'error_code' in err
187
+ ? err.error_code : 0;
188
+ // Handle TG rate limit (429) β€” retry
189
+ if (errorCode === 429) {
163
190
  const retryAfter = err.parameters?.retry_after ?? 5;
164
191
  this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
165
192
  await sleep(retryAfter * 1000);
166
193
  return this._doSendOrEdit(text);
167
194
  }
168
- // Ignore "message is not modified" errors
195
+ // Ignore "message is not modified" errors (harmless)
169
196
  if (err instanceof Error && err.message.includes('message is not modified'))
170
197
  return;
171
- throw err;
198
+ // 400 (bad request), 403 (forbidden), and all other errors β€” log and skip, never throw
199
+ this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit β€” skipping');
172
200
  }
173
201
  }
174
202
  async sendImage() {
@@ -181,16 +209,149 @@ export class StreamAccumulator {
181
209
  }
182
210
  catch (err) {
183
211
  // Fall back to text indicator on failure
212
+ this.logger?.error?.({ err }, 'Failed to send image');
184
213
  this.buffer += '\n[Image could not be sent]';
185
214
  }
186
215
  this.imageBase64Buffer = '';
187
216
  }
188
- async showToolIndicator(toolName) {
189
- const bufferHtml = this.buffer ? makeHtmlSafe(this.buffer) : '';
190
- const indicator = bufferHtml
191
- ? `${bufferHtml}\n\n<i>Using ${escapeHtml(toolName)}...</i>`
192
- : `<i>Using ${escapeHtml(toolName)}...</i>`;
193
- await this.sendOrEdit(indicator, true);
217
+ /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
218
+ async sendToolIndicator(blockId, toolName) {
219
+ const startTime = Date.now();
220
+ this.sendQueue = this.sendQueue.then(async () => {
221
+ try {
222
+ const html = `<blockquote expandable>⚑ ${escapeHtml(toolName)}…</blockquote>`;
223
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
224
+ this.toolMessages.set(blockId, { msgId, toolName, startTime });
225
+ }
226
+ catch (err) {
227
+ // Tool indicator is non-critical β€” log and continue
228
+ this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
229
+ }
230
+ }).catch(err => {
231
+ this.logger?.error?.({ err }, 'sendToolIndicator queue error');
232
+ });
233
+ return this.sendQueue;
234
+ }
235
+ /** Update a tool indicator message with input preview once the JSON value is complete. */
236
+ toolIndicatorLastSummary = new Map(); // blockId β†’ last rendered summary
237
+ async updateToolIndicatorWithInput(blockId) {
238
+ const entry = this.toolMessages.get(blockId);
239
+ if (!entry)
240
+ return;
241
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
242
+ // Only extract from complete JSON (try parse succeeds) or complete regex match
243
+ // (the value must have a closing quote to avoid truncated paths)
244
+ const summary = extractToolInputSummary(entry.toolName, inputJson, 120, true);
245
+ if (!summary)
246
+ return; // not enough input yet or value still streaming
247
+ // Skip if summary hasn't changed since last edit
248
+ if (this.toolIndicatorLastSummary.get(blockId) === summary)
249
+ return;
250
+ this.toolIndicatorLastSummary.set(blockId, summary);
251
+ const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
252
+ const html = `<blockquote expandable>⚑ ${escapeHtml(entry.toolName)}…${codeLine}</blockquote>`;
253
+ this.sendQueue = this.sendQueue.then(async () => {
254
+ try {
255
+ await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
256
+ }
257
+ catch (err) {
258
+ // "message is not modified" or other edit failure β€” non-critical
259
+ this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
260
+ }
261
+ }).catch(err => {
262
+ this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
263
+ });
264
+ return this.sendQueue;
265
+ }
266
+ /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
267
+ async resolveToolMessage(blockId, isError, errorMessage, resultContent) {
268
+ const entry = this.toolMessages.get(blockId);
269
+ if (!entry)
270
+ return;
271
+ const { msgId, toolName, startTime } = entry;
272
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
273
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
274
+ const summary = extractToolInputSummary(toolName, inputJson);
275
+ const resultStat = resultContent ? extractToolResultStat(toolName, resultContent) : '';
276
+ const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
277
+ const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
278
+ const icon = isError ? '❌' : 'βœ…';
279
+ const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
280
+ // Clean up input buffer
281
+ this.toolInputBuffers.delete(blockId);
282
+ this.toolIndicatorLastSummary.delete(blockId);
283
+ this.sendQueue = this.sendQueue.then(async () => {
284
+ try {
285
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
286
+ }
287
+ catch (err) {
288
+ // Edit failure on resolve β€” non-critical
289
+ this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
290
+ }
291
+ }).catch(err => {
292
+ this.logger?.error?.({ err }, 'resolveToolMessage queue error');
293
+ });
294
+ return this.sendQueue;
295
+ }
296
+ /** Edit a specific tool indicator message by block ID. */
297
+ async editToolMessage(blockId, html) {
298
+ const entry = this.toolMessages.get(blockId);
299
+ if (!entry)
300
+ return;
301
+ this.sendQueue = this.sendQueue.then(async () => {
302
+ try {
303
+ await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
304
+ }
305
+ catch (err) {
306
+ // Edit failure β€” non-critical
307
+ this.logger?.debug?.({ err }, 'Failed to edit tool message');
308
+ }
309
+ }).catch(err => {
310
+ this.logger?.error?.({ err }, 'editToolMessage queue error');
311
+ });
312
+ return this.sendQueue;
313
+ }
314
+ /** Delete a specific tool indicator message by block ID. */
315
+ async deleteToolMessage(blockId) {
316
+ const entry = this.toolMessages.get(blockId);
317
+ if (!entry)
318
+ return;
319
+ this.toolMessages.delete(blockId);
320
+ this.sendQueue = this.sendQueue.then(async () => {
321
+ try {
322
+ if (this.sender.deleteMessage) {
323
+ await this.sender.deleteMessage(this.chatId, entry.msgId);
324
+ }
325
+ }
326
+ catch (err) {
327
+ // Delete failure β€” non-critical
328
+ this.logger?.debug?.({ err }, 'Failed to delete tool message');
329
+ }
330
+ }).catch(err => {
331
+ this.logger?.error?.({ err }, 'deleteToolMessage queue error');
332
+ });
333
+ return this.sendQueue;
334
+ }
335
+ /** Delete all tool indicator messages. */
336
+ async deleteAllToolMessages() {
337
+ const ids = [...this.toolMessages.values()];
338
+ this.toolMessages.clear();
339
+ if (!this.sender.deleteMessage)
340
+ return;
341
+ this.sendQueue = this.sendQueue.then(async () => {
342
+ for (const { msgId } of ids) {
343
+ try {
344
+ await this.sender.deleteMessage(this.chatId, msgId);
345
+ }
346
+ catch (err) {
347
+ // Delete failure β€” non-critical
348
+ this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
349
+ }
350
+ }
351
+ }).catch(err => {
352
+ this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
353
+ });
354
+ return this.sendQueue;
194
355
  }
195
356
  async throttledEdit() {
196
357
  const now = Date.now();
@@ -301,24 +462,24 @@ export class StreamAccumulator {
301
462
  this.editTimer = null;
302
463
  }
303
464
  }
304
- /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
465
+ /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
466
+ * toolMessages persists across resets β€” they are independent of the text accumulator. */
305
467
  softReset() {
306
468
  this.buffer = '';
307
469
  this.thinkingBuffer = '';
308
470
  this.imageBase64Buffer = '';
309
471
  this.currentBlockType = null;
472
+ this.currentToolBlockId = null;
310
473
  this.lastEditTime = 0;
311
474
  this.thinkingIndicatorShown = false;
312
- this.toolIndicators = [];
313
475
  this.finished = false;
314
476
  this.turnUsage = null;
315
- this.clearEditTimer(); // Ensure cleanup
477
+ this.clearEditTimer();
316
478
  }
317
479
  /** Full reset: also clears tgMessageId (next send creates a new message).
318
- * Chains on the existing sendQueue so any pending finalize() edits complete first. */
480
+ * Chains on the existing sendQueue so any pending finalize() edits complete first.
481
+ * toolMessages persists β€” they are independent fire-and-forget messages. */
319
482
  reset() {
320
- // Chain on the existing queue so pending sends (e.g. finalize) complete
321
- // before the new turn starts sending.
322
483
  const prevQueue = this.sendQueue;
323
484
  this.softReset();
324
485
  this.tgMessageId = null;
@@ -327,6 +488,130 @@ export class StreamAccumulator {
327
488
  }
328
489
  }
329
490
  // ── Helpers ──
491
+ /** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
492
+ /** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts β†’ KYO/src/foo.ts */
493
+ function shortenPath(p) {
494
+ // Strip common prefixes
495
+ return p
496
+ .replace(/^\/home\/[^/]+\/Botverse\//, '')
497
+ .replace(/^\/home\/[^/]+\/Projects\//, '')
498
+ .replace(/^\/home\/[^/]+\//, '~/');
499
+ }
500
+ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
501
+ if (!inputJson)
502
+ return null;
503
+ // Determine which field(s) to look for based on tool name
504
+ const fieldsByTool = {
505
+ Bash: ['command'],
506
+ Read: ['file_path', 'path'],
507
+ Write: ['file_path', 'path'],
508
+ Edit: ['file_path', 'path'],
509
+ MultiEdit: ['file_path', 'path'],
510
+ Search: ['pattern', 'query'],
511
+ Grep: ['pattern', 'query'],
512
+ Glob: ['pattern'],
513
+ TodoWrite: ['content', 'task', 'text'],
514
+ };
515
+ const skipTools = new Set(['TodoRead']);
516
+ if (skipTools.has(toolName))
517
+ return null;
518
+ const fields = fieldsByTool[toolName];
519
+ const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
520
+ // Try parsing complete JSON first
521
+ try {
522
+ const parsed = JSON.parse(inputJson);
523
+ if (fields) {
524
+ for (const f of fields) {
525
+ if (typeof parsed[f] === 'string' && parsed[f].trim()) {
526
+ let val = parsed[f].trim();
527
+ if (isPathTool)
528
+ val = shortenPath(val);
529
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
530
+ }
531
+ }
532
+ }
533
+ // Default: first string value
534
+ for (const val of Object.values(parsed)) {
535
+ if (typeof val === 'string' && val.trim()) {
536
+ const v = val.trim();
537
+ return v.length > maxLen ? v.slice(0, maxLen) + '…' : v;
538
+ }
539
+ }
540
+ return null;
541
+ }
542
+ catch {
543
+ if (requireComplete)
544
+ return null; // Only use fully-parsed JSON when requireComplete
545
+ // Partial JSON β€” regex extraction (only used for final resolve, not live preview)
546
+ const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
547
+ for (const key of targetFields) {
548
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
549
+ const m = inputJson.match(re);
550
+ if (m?.[1]) {
551
+ let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
552
+ if (isPathTool)
553
+ val = shortenPath(val);
554
+ return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
555
+ }
556
+ }
557
+ return null;
558
+ }
559
+ }
560
+ /** Extract a compact stat from a tool result for display in the indicator. */
561
+ function extractToolResultStat(toolName, content) {
562
+ if (!content)
563
+ return '';
564
+ const first = content.split('\n')[0].trim();
565
+ switch (toolName) {
566
+ case 'Write': {
567
+ // "Successfully wrote 42 lines to src/foo.ts" or byte count
568
+ const lines = content.match(/(\d+)\s*lines?/i);
569
+ const bytes = content.match(/(\d[\d,.]*)\s*(bytes?|KB|MB)/i);
570
+ if (lines)
571
+ return `${lines[1]} lines`;
572
+ if (bytes)
573
+ return `${bytes[1]} ${bytes[2]}`;
574
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
575
+ }
576
+ case 'Edit':
577
+ case 'MultiEdit': {
578
+ // "Edited src/foo.ts: replaced 3 lines" or snippet
579
+ const replaced = content.match(/replaced\s+(\d+)\s*lines?/i);
580
+ const chars = content.match(/(\d+)\s*characters?/i);
581
+ if (replaced)
582
+ return `${replaced[1]} lines replaced`;
583
+ if (chars)
584
+ return `${chars[1]} chars changed`;
585
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
586
+ }
587
+ case 'Bash': {
588
+ // Show exit code or first line of output (truncated)
589
+ const exit = content.match(/exit\s*code\s*[:=]?\s*(\d+)/i);
590
+ if (exit && exit[1] !== '0')
591
+ return `exit ${exit[1]}`;
592
+ // Count output lines
593
+ const outputLines = content.split('\n').filter(l => l.trim()).length;
594
+ if (outputLines > 3)
595
+ return `${outputLines} lines output`;
596
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
597
+ }
598
+ case 'Read': {
599
+ const lines = content.split('\n').length;
600
+ return `${lines} lines`;
601
+ }
602
+ case 'Search':
603
+ case 'Grep':
604
+ case 'Glob': {
605
+ const matches = content.match(/(\d+)\s*(match|result|file)/i);
606
+ if (matches)
607
+ return `${matches[1]} ${matches[2]}s`;
608
+ const resultLines = content.split('\n').filter(l => l.trim()).length;
609
+ return `${resultLines} results`;
610
+ }
611
+ default:
612
+ return first.length > 60 ? first.slice(0, 60) + '…' : first;
613
+ }
614
+ }
330
615
  function findSplitPoint(text, threshold) {
331
616
  // Try to split at paragraph break
332
617
  const paragraphBreak = text.lastIndexOf('\n\n', threshold);
@@ -397,36 +682,46 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
397
682
  }
398
683
  return '';
399
684
  }
685
+ /** Label fields in priority order (index 0 = highest priority). */
686
+ const LABEL_FIELDS = ['name', 'description', 'subagent_type', 'team_name'];
687
+ /** Priority index for a label source field. Lower = better. */
688
+ export function labelFieldPriority(field) {
689
+ if (!field)
690
+ return LABEL_FIELDS.length + 1; // worst
691
+ const idx = LABEL_FIELDS.indexOf(field);
692
+ return idx >= 0 ? idx : LABEL_FIELDS.length;
693
+ }
400
694
  /**
401
695
  * Extract a human-readable label for a sub-agent from its JSON tool input.
402
- * Uses CC's Task tool structured fields: name, description, subagent_type, team_name.
403
- * No regex guessing β€” purely structural JSON field extraction.
696
+ * Returns { label, field } so callers can track priority and upgrade labels
697
+ * when higher-priority fields become available during streaming.
404
698
  */
405
699
  export function extractAgentLabel(jsonInput) {
406
- // Priority order of CC Task tool fields
407
- const labelFields = ['name', 'description', 'subagent_type', 'team_name'];
408
700
  const summaryField = 'prompt'; // last resort β€” first line of prompt
409
701
  try {
410
702
  const parsed = JSON.parse(jsonInput);
411
- for (const key of labelFields) {
703
+ for (const key of LABEL_FIELDS) {
412
704
  const val = parsed[key];
413
705
  if (typeof val === 'string' && val.trim()) {
414
- return val.trim().slice(0, 80);
706
+ return { label: val.trim().slice(0, 80), field: key };
415
707
  }
416
708
  }
417
709
  if (typeof parsed[summaryField] === 'string' && parsed[summaryField].trim()) {
418
710
  const firstLine = parsed[summaryField].trim().split('\n')[0];
419
- return firstLine.length > 60 ? firstLine.slice(0, 60) + '…' : firstLine;
711
+ const label = firstLine.length > 60 ? firstLine.slice(0, 60) + '…' : firstLine;
712
+ return { label, field: 'prompt' };
420
713
  }
421
- return '';
714
+ return { label: '', field: null };
422
715
  }
423
716
  catch {
424
717
  // JSON incomplete during streaming β€” extract first complete field value
425
- return extractFieldFromPartialJson(jsonInput, labelFields) ?? '';
718
+ const result = extractFieldFromPartialJsonWithField(jsonInput, LABEL_FIELDS);
719
+ return result ?? { label: '', field: null };
426
720
  }
427
721
  }
428
- /** Extract the first complete string value for any of the given keys from partial JSON. */
429
- function extractFieldFromPartialJson(input, keys) {
722
+ /** Extract the first complete string value for any of the given keys from partial JSON.
723
+ * Returns { label, field } so callers know which field matched. */
724
+ function extractFieldFromPartialJsonWithField(input, keys) {
430
725
  for (const key of keys) {
431
726
  const idx = input.indexOf(`"${key}"`);
432
727
  if (idx === -1)
@@ -447,7 +742,7 @@ function extractFieldFromPartialJson(input, keys) {
447
742
  }
448
743
  else if (afterColon[i] === '"') {
449
744
  if (value.trim())
450
- return value.trim().slice(0, 80);
745
+ return { label: value.trim().slice(0, 80), field: key };
451
746
  break;
452
747
  }
453
748
  else {
@@ -500,8 +795,10 @@ export class SubAgentTracker {
500
795
  try {
501
796
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
502
797
  }
503
- catch { /* ignore */ }
504
- });
798
+ catch (err) {
799
+ // Non-critical β€” edit failure on dispatched agent status
800
+ }
801
+ }).catch(() => { });
505
802
  }
506
803
  }
507
804
  async handleEvent(event) {
@@ -581,9 +878,9 @@ export class SubAgentTracker {
581
878
  await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
582
879
  }
583
880
  catch {
584
- // Ignore edit failures
881
+ // Edit failure on tool result β€” non-critical
585
882
  }
586
- });
883
+ }).catch(() => { });
587
884
  await this.sendQueue;
588
885
  }
589
886
  async onBlockStart(event) {
@@ -599,6 +896,7 @@ export class SubAgentTracker {
599
896
  tgMessageId: null,
600
897
  status: 'running',
601
898
  label: '',
899
+ labelField: null,
602
900
  agentName: '',
603
901
  inputPreview: '',
604
902
  dispatchedAt: null,
@@ -619,9 +917,9 @@ export class SubAgentTracker {
619
917
  this.consolidatedAgentMsgId = msgId;
620
918
  }
621
919
  catch {
622
- // Silently ignore
920
+ // Sub-agent indicator is non-critical
623
921
  }
624
- });
922
+ }).catch(() => { });
625
923
  await this.sendQueue;
626
924
  }
627
925
  }
@@ -643,8 +941,10 @@ export class SubAgentTracker {
643
941
  try {
644
942
  await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
645
943
  }
646
- catch { /* ignore */ }
647
- });
944
+ catch {
945
+ // Consolidated message edit failure β€” non-critical
946
+ }
947
+ }).catch(() => { });
648
948
  }
649
949
  async onInputDelta(event) {
650
950
  const toolUseId = this.blockToAgent.get(event.index);
@@ -671,13 +971,13 @@ export class SubAgentTracker {
671
971
  info.agentName = nameMatch[1];
672
972
  }
673
973
  }
674
- // Try to extract an agent label
675
- if (!info.label) {
676
- const label = extractAgentLabel(info.inputPreview);
677
- if (label) {
678
- info.label = label;
679
- this.updateConsolidatedAgentMessage();
680
- }
974
+ // Try to extract an agent label β€” keep trying for higher-priority fields
975
+ // (e.g., upgrade from subagent_type "general-purpose" to description "Fix the bug")
976
+ const extracted = extractAgentLabel(info.inputPreview);
977
+ if (extracted.label && labelFieldPriority(extracted.field) < labelFieldPriority(info.labelField)) {
978
+ info.label = extracted.label;
979
+ info.labelField = extracted.field;
980
+ this.updateConsolidatedAgentMessage();
681
981
  }
682
982
  }
683
983
  async onBlockStop(event) {
@@ -690,11 +990,11 @@ export class SubAgentTracker {
690
990
  // content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
691
991
  info.status = 'dispatched';
692
992
  info.dispatchedAt = Date.now();
693
- // Final chance to extract label from complete input
694
- if (!info.label) {
695
- const label = extractAgentLabel(info.inputPreview);
696
- if (label)
697
- info.label = label;
993
+ // Final chance to extract label from complete input (may upgrade to higher-priority field)
994
+ const finalExtracted = extractAgentLabel(info.inputPreview);
995
+ if (finalExtracted.label && labelFieldPriority(finalExtracted.field) < labelFieldPriority(info.labelField)) {
996
+ info.label = finalExtracted.label;
997
+ info.labelField = finalExtracted.field;
698
998
  }
699
999
  this.updateConsolidatedAgentMessage();
700
1000
  // Start elapsed timer β€” update every 15s to show progress
@@ -786,8 +1086,10 @@ export class SubAgentTracker {
786
1086
  try {
787
1087
  await this.sender.setReaction?.(this.chatId, msgId, emoji);
788
1088
  }
789
- catch { /* ignore β€” reaction might not be supported */ }
790
- });
1089
+ catch {
1090
+ // Reaction failure β€” non-critical, might not be supported
1091
+ }
1092
+ }).catch(() => { });
791
1093
  }
792
1094
  // Check if ALL dispatched agents are now completed
793
1095
  if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
@@ -815,6 +1117,79 @@ export class SubAgentTracker {
815
1117
  }
816
1118
  return null;
817
1119
  }
1120
+ /** Handle a system task_started event β€” update the sub-agent status display. */
1121
+ handleTaskStarted(toolUseId, description, taskType) {
1122
+ const info = this.agents.get(toolUseId);
1123
+ if (!info)
1124
+ return;
1125
+ // Use task_started description as label if we don't have one yet or current is low-priority
1126
+ if (description && labelFieldPriority('description') < labelFieldPriority(info.labelField)) {
1127
+ info.label = description.slice(0, 80);
1128
+ info.labelField = 'description';
1129
+ }
1130
+ info.status = 'dispatched';
1131
+ if (!info.dispatchedAt)
1132
+ info.dispatchedAt = Date.now();
1133
+ this.updateConsolidatedAgentMessage();
1134
+ }
1135
+ /** Handle a system task_progress event β€” update the sub-agent status with current activity. */
1136
+ handleTaskProgress(toolUseId, description, lastToolName) {
1137
+ const info = this.agents.get(toolUseId);
1138
+ if (!info)
1139
+ return;
1140
+ if (info.status === 'completed')
1141
+ return; // Don't update completed agents
1142
+ // Update the consolidated message with progress info
1143
+ this.updateConsolidatedAgentMessageWithProgress(toolUseId, description, lastToolName);
1144
+ }
1145
+ /** Handle a system task_completed event. */
1146
+ handleTaskCompleted(toolUseId) {
1147
+ const info = this.agents.get(toolUseId);
1148
+ if (!info || info.status === 'completed')
1149
+ return;
1150
+ info.status = 'completed';
1151
+ this.updateConsolidatedAgentMessage();
1152
+ // Check if all agents are done
1153
+ const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
1154
+ if (allDone && this.onAllReported) {
1155
+ this.onAllReported();
1156
+ this.stopMailboxWatch();
1157
+ }
1158
+ }
1159
+ /** Build and edit the shared sub-agent status message with progress info. */
1160
+ updateConsolidatedAgentMessageWithProgress(progressToolUseId, progressDesc, lastTool) {
1161
+ if (!this.consolidatedAgentMsgId)
1162
+ return;
1163
+ const msgId = this.consolidatedAgentMsgId;
1164
+ const lines = [];
1165
+ for (const info of this.agents.values()) {
1166
+ const label = info.label || info.agentName || info.toolName;
1167
+ if (info.toolUseId === progressToolUseId && info.status !== 'completed') {
1168
+ const toolInfo = lastTool ? ` (${lastTool})` : '';
1169
+ const desc = progressDesc ? `: ${progressDesc.slice(0, 60)}` : '';
1170
+ lines.push(`πŸ€– ${escapeHtml(label)} β€” Working${toolInfo}${desc}`);
1171
+ }
1172
+ else {
1173
+ const status = info.status === 'completed' ? 'βœ… Done'
1174
+ : info.status === 'dispatched' ? 'Waiting for results…'
1175
+ : 'Working…';
1176
+ lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1177
+ }
1178
+ }
1179
+ const text = lines.join('\n');
1180
+ this.sendQueue = this.sendQueue.then(async () => {
1181
+ try {
1182
+ await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1183
+ }
1184
+ catch {
1185
+ // Progress message edit failure β€” non-critical
1186
+ }
1187
+ }).catch(() => { });
1188
+ }
1189
+ /** Find a tracked sub-agent by tool_use_id. */
1190
+ getAgentByToolUseId(toolUseId) {
1191
+ return this.agents.get(toolUseId);
1192
+ }
818
1193
  reset() {
819
1194
  // Stop mailbox watching
820
1195
  this.stopMailboxWatch();