@fonz/tgcc 0.6.17 β†’ 0.6.19

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
@@ -10,6 +10,12 @@ export function escapeHtml(text) {
10
10
  .replace(/</g, '&lt;')
11
11
  .replace(/>/g, '&gt;');
12
12
  }
13
+ /** Create visually distinct system message with enhanced styling */
14
+ export function formatSystemMessage(type, content, expandable = false) {
15
+ const emoji = { thinking: 'πŸ’­', tool: '⚑', usage: 'πŸ“Š', error: '⚠️', status: 'ℹ️' }[type];
16
+ const wrapper = expandable ? 'blockquote expandable' : 'blockquote';
17
+ return `<${wrapper}>${emoji} ${content}</${wrapper.split(' ')[0]}>`;
18
+ }
13
19
  /**
14
20
  * Convert markdown text to Telegram-safe HTML using the marked library.
15
21
  * Replaces the old hand-rolled implementation with a proper markdown parser.
@@ -28,7 +34,63 @@ export function makeHtmlSafe(text) {
28
34
  export function makeMarkdownSafe(text) {
29
35
  return makeHtmlSafe(text);
30
36
  }
31
- // ── Stream Accumulator ──
37
+ function toolEmoji(toolName) {
38
+ switch (toolName) {
39
+ case 'Read': return 'πŸ‘“';
40
+ case 'Write': return '✏️';
41
+ case 'Edit':
42
+ case 'MultiEdit': return '✏️';
43
+ case 'Grep':
44
+ case 'Search':
45
+ case 'Glob': return 'πŸ”';
46
+ case 'WebFetch':
47
+ case 'WebSearch': return '🌐';
48
+ default: return '⚑';
49
+ }
50
+ }
51
+ /** Render a single segment to its HTML string. */
52
+ function renderSegment(seg) {
53
+ switch (seg.type) {
54
+ case 'thinking':
55
+ return `<blockquote expandable>πŸ’­ ${seg.rawText ? markdownToTelegramHtml(seg.rawText.length > 1024 ? seg.rawText.slice(0, 1024) + '…' : seg.rawText) : 'Processing…'}</blockquote>`;
56
+ case 'text':
57
+ return seg.rawText ? makeHtmlSafe(seg.rawText) : '';
58
+ case 'tool': {
59
+ const emoji = toolEmoji(seg.toolName);
60
+ const inputPart = seg.inputPreview ? ` <code>${escapeHtml(seg.inputPreview)}</code>` : '';
61
+ if (seg.status === 'resolved') {
62
+ const resultLine = seg.resultStat ? `\n<code>${escapeHtml(seg.resultStat)}</code>` : '';
63
+ return `<blockquote>${emoji} ${seg.elapsed ?? '?'} Β· βœ”${inputPart}${resultLine}</blockquote>`;
64
+ }
65
+ else if (seg.status === 'error') {
66
+ return `<blockquote>${emoji} ${seg.elapsed ?? '?'} · ✘${inputPart}</blockquote>`;
67
+ }
68
+ else {
69
+ return `<blockquote>${emoji}${inputPart || ' …'}</blockquote>`;
70
+ }
71
+ }
72
+ case 'subagent': {
73
+ const label = seg.label || seg.toolName;
74
+ const elapsed = formatElapsed(Date.now() - seg.startTime);
75
+ const progressBlock = seg.progressLines.length > 0
76
+ ? '\n' + seg.progressLines.join('\n')
77
+ : '';
78
+ if (seg.status === 'completed') {
79
+ return `<blockquote>πŸ€– ${escapeHtml(label)} β€” βœ… Done (${elapsed})${progressBlock}</blockquote>`;
80
+ }
81
+ else {
82
+ return `<blockquote>πŸ€– ${escapeHtml(label)} β€” Working (${elapsed})…${progressBlock}</blockquote>`;
83
+ }
84
+ }
85
+ case 'supervisor':
86
+ case 'usage':
87
+ case 'image':
88
+ return seg.content;
89
+ default:
90
+ return '';
91
+ }
92
+ }
93
+ // ── Stream Accumulator (single-bubble FIFO) ──
32
94
  export class StreamAccumulator {
33
95
  chatId;
34
96
  sender;
@@ -36,38 +98,44 @@ export class StreamAccumulator {
36
98
  splitThreshold;
37
99
  logger;
38
100
  onError;
101
+ // Segment FIFO
102
+ segments = [];
39
103
  // State
40
104
  tgMessageId = null;
41
- buffer = '';
42
- thinkingBuffer = '';
43
- imageBase64Buffer = '';
44
- currentBlockType = null;
45
- lastEditTime = 0;
46
- editTimer = null;
47
- thinkingIndicatorShown = false;
48
- messageIds = []; // all message IDs sent during this turn
49
- finished = false;
105
+ messageIds = [];
106
+ sealed = false;
50
107
  sendQueue = Promise.resolve();
51
108
  turnUsage = null;
52
- /** Usage from the most recent message_start event β€” represents a single API call's context (not cumulative). */
53
109
  _lastMsgStartCtx = null;
54
- // Per-tool-use independent indicator messages (persists across resets)
55
- toolMessages = new Map();
56
- toolInputBuffers = new Map(); // tool block ID β†’ accumulated input JSON
57
- currentToolBlockId = null;
110
+ // Per-block streaming state
111
+ currentBlockType = null;
112
+ currentBlockId = null;
113
+ currentSegmentIdx = -1; // index into segments[] for currently-building block
114
+ // Tool streaming state
115
+ toolInputBuffers = new Map(); // blockId β†’ accumulated JSON input
116
+ // Image streaming state
117
+ imageBase64Buffer = '';
118
+ // Rate limiting / render scheduling
119
+ lastEditTime = 0;
120
+ flushTimer = null;
121
+ dirty = false;
122
+ // Delayed first send (fix: don't create TG message until real content arrives)
123
+ turnStartTime = 0;
124
+ firstSendReady = true; // true until first reset() β€” pre-turn sends are unrestricted
125
+ firstSendTimer = null;
126
+ // Tool hide timers (fix: don't flash ⚑ for fast tools that resolve <500ms)
127
+ toolHideTimers = new Map();
58
128
  constructor(options) {
59
129
  this.chatId = options.chatId;
60
130
  this.sender = options.sender;
61
131
  this.editIntervalMs = options.editIntervalMs ?? 1000;
62
- this.splitThreshold = options.splitThreshold ?? 4000;
132
+ this.splitThreshold = options.splitThreshold ?? 3500;
63
133
  this.logger = options.logger;
64
134
  this.onError = options.onError;
65
135
  }
66
136
  get allMessageIds() { return [...this.messageIds]; }
67
137
  /** Set usage stats for the current turn (called from bridge on result event) */
68
138
  setTurnUsage(usage) {
69
- // Merge in per-API-call ctx tokens if we captured them from message_start events.
70
- // These are bounded by the context window (unlike result event usage which accumulates across tool loops).
71
139
  if (this._lastMsgStartCtx) {
72
140
  this.turnUsage = {
73
141
  ...usage,
@@ -80,12 +148,20 @@ export class StreamAccumulator {
80
148
  this.turnUsage = usage;
81
149
  }
82
150
  }
151
+ /** Append a supervisor message segment. Renders in stream order with everything else. */
152
+ addSupervisorMessage(text) {
153
+ const preview = text.length > 500 ? text.slice(0, 500) + '…' : text;
154
+ const seg = {
155
+ type: 'supervisor',
156
+ content: `<blockquote>🦞 ${escapeHtml(preview)}</blockquote>`,
157
+ };
158
+ this.segments.push(seg);
159
+ this.requestRender();
160
+ }
83
161
  // ── Process stream events ──
84
162
  async handleEvent(event) {
85
163
  switch (event.type) {
86
164
  case 'message_start': {
87
- // Bridge handles reset decision - no automatic reset here
88
- // Capture per-API-call token counts for accurate context % (not cumulative like result event)
89
165
  const msUsage = event.message?.usage;
90
166
  if (msUsage) {
91
167
  this._lastMsgStartCtx = {
@@ -97,81 +173,129 @@ export class StreamAccumulator {
97
173
  break;
98
174
  }
99
175
  case 'content_block_start':
100
- await this.onContentBlockStart(event);
176
+ this.onContentBlockStart(event);
101
177
  break;
102
178
  case 'content_block_delta':
103
179
  await this.onContentBlockDelta(event);
104
180
  break;
105
181
  case 'content_block_stop':
106
- if (this.currentBlockType === 'thinking' && this.thinkingBuffer) {
107
- // Thinking block complete β€” store for later rendering with text
108
- // Will be prepended as expandable blockquote when text starts or on finalize
109
- }
110
- else if (this.currentBlockType === 'image' && this.imageBase64Buffer) {
111
- await this.sendImage();
112
- }
113
- this.currentBlockType = null;
182
+ await this.onContentBlockStop(event);
114
183
  break;
115
184
  case 'message_stop':
116
- await this.finalize();
185
+ // message_stop within a tool-use loop β€” finalize is called separately by bridge on `result`
117
186
  break;
118
187
  }
119
188
  }
120
- async onContentBlockStart(event) {
189
+ onContentBlockStart(event) {
121
190
  const blockType = event.content_block.type;
191
+ this.currentBlockType = blockType;
122
192
  if (blockType === 'thinking') {
123
- this.currentBlockType = 'thinking';
124
- if (!this.thinkingIndicatorShown && !this.buffer) {
125
- await this.sendOrEdit('<blockquote>πŸ’­ Thinking...</blockquote>', true);
126
- this.thinkingIndicatorShown = true;
127
- }
193
+ const seg = { type: 'thinking', rawText: '', content: '' };
194
+ seg.content = renderSegment(seg);
195
+ this.segments.push(seg);
196
+ this.currentSegmentIdx = this.segments.length - 1;
197
+ this.requestRender();
128
198
  }
129
199
  else if (blockType === 'text') {
130
- this.currentBlockType = 'text';
131
- // Text always gets its own message β€” don't touch tool indicator messages
132
- if (!this.tgMessageId) {
133
- // Will create a new message on next sendOrEdit
134
- }
200
+ const seg = { type: 'text', rawText: '', content: '' };
201
+ this.segments.push(seg);
202
+ this.currentSegmentIdx = this.segments.length - 1;
135
203
  }
136
204
  else if (blockType === 'tool_use') {
137
- this.currentBlockType = 'tool_use';
138
205
  const block = event.content_block;
139
- this.currentToolBlockId = block.id;
140
- // Send an independent indicator message for this tool_use block
141
- await this.sendToolIndicator(block.id, block.name);
206
+ this.currentBlockId = block.id;
207
+ this.toolInputBuffers.set(block.id, '');
208
+ if (isSubAgentTool(block.name)) {
209
+ const seg = {
210
+ type: 'subagent',
211
+ id: block.id,
212
+ toolName: block.name,
213
+ label: '',
214
+ status: 'running',
215
+ startTime: Date.now(),
216
+ progressLines: [],
217
+ content: '',
218
+ };
219
+ seg.content = renderSegment(seg);
220
+ this.segments.push(seg);
221
+ this.currentSegmentIdx = this.segments.length - 1;
222
+ }
223
+ else {
224
+ const seg = {
225
+ type: 'tool',
226
+ id: block.id,
227
+ toolName: block.name,
228
+ status: 'pending',
229
+ startTime: Date.now(),
230
+ content: '',
231
+ };
232
+ seg.content = renderSegment(seg);
233
+ this.segments.push(seg);
234
+ this.currentSegmentIdx = this.segments.length - 1;
235
+ // Suppress the ⚑ pending indicator for 500ms. If the tool resolves within that window
236
+ // the hide timer is cancelled in resolveToolMessage and we render directly as βœ….
237
+ const toolBlockId = block.id;
238
+ this.toolHideTimers.set(toolBlockId, setTimeout(() => {
239
+ this.toolHideTimers.delete(toolBlockId);
240
+ this.requestRender();
241
+ }, 500));
242
+ }
243
+ this.requestRender();
142
244
  }
143
245
  else if (blockType === 'image') {
144
- this.currentBlockType = 'image';
145
246
  this.imageBase64Buffer = '';
146
247
  }
147
248
  }
148
249
  async onContentBlockDelta(event) {
149
250
  if (this.currentBlockType === 'text' && 'delta' in event) {
150
251
  const delta = event.delta;
151
- if (delta?.type === 'text_delta') {
152
- this.buffer += delta.text;
153
- // Force split/truncation if buffer exceeds 50KB
154
- if (this.buffer.length > 50_000) {
155
- await this.forceSplitOrTruncate();
156
- return; // Skip throttledEdit - already handled in forceSplitOrTruncate
252
+ if (delta?.type === 'text_delta' && this.currentSegmentIdx >= 0) {
253
+ const seg = this.segments[this.currentSegmentIdx];
254
+ seg.rawText += delta.text;
255
+ seg.content = renderSegment(seg);
256
+ if (seg.rawText.length > 50_000) {
257
+ await this.forceSplitText(seg);
258
+ return;
157
259
  }
158
- await this.throttledEdit();
260
+ this.requestRender();
159
261
  }
160
262
  }
161
263
  else if (this.currentBlockType === 'thinking' && 'delta' in event) {
162
264
  const delta = event.delta;
163
- if (delta?.type === 'thinking_delta' && delta.thinking) {
164
- this.thinkingBuffer += delta.thinking;
265
+ if (delta?.type === 'thinking_delta' && delta.thinking && this.currentSegmentIdx >= 0) {
266
+ const seg = this.segments[this.currentSegmentIdx];
267
+ seg.rawText += delta.thinking;
268
+ seg.content = renderSegment(seg);
269
+ this.requestRender();
165
270
  }
166
271
  }
167
272
  else if (this.currentBlockType === 'tool_use' && 'delta' in event) {
168
273
  const delta = event.delta;
169
- if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentToolBlockId) {
170
- const blockId = this.currentToolBlockId;
274
+ if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentBlockId) {
275
+ const blockId = this.currentBlockId;
171
276
  const prev = this.toolInputBuffers.get(blockId) ?? '';
172
- this.toolInputBuffers.set(blockId, prev + delta.partial_json);
173
- // Update indicator with input preview once we have enough
174
- await this.updateToolIndicatorWithInput(blockId);
277
+ const next = prev + delta.partial_json;
278
+ this.toolInputBuffers.set(blockId, next);
279
+ // Update segment preview if we have enough input
280
+ if (this.currentSegmentIdx >= 0) {
281
+ const seg = this.segments[this.currentSegmentIdx];
282
+ if (seg.type === 'tool' || seg.type === 'subagent') {
283
+ const toolName = seg.type === 'tool' ? seg.toolName : seg.toolName;
284
+ const summary = extractToolInputSummary(toolName, next, 80, true);
285
+ if (summary) {
286
+ seg.inputPreview = summary;
287
+ }
288
+ if (seg.type === 'subagent') {
289
+ const extracted = extractAgentLabel(next);
290
+ if (extracted.label && labelFieldPriority(extracted.field) < labelFieldPriority(seg.labelField ?? null)) {
291
+ seg.label = extracted.label;
292
+ seg.labelField = extracted.field;
293
+ }
294
+ }
295
+ seg.content = renderSegment(seg);
296
+ this.requestRender();
297
+ }
298
+ }
175
299
  }
176
300
  }
177
301
  else if (this.currentBlockType === 'image' && 'delta' in event) {
@@ -181,45 +305,336 @@ export class StreamAccumulator {
181
305
  }
182
306
  }
183
307
  }
308
+ async onContentBlockStop(_event) {
309
+ if (this.currentBlockType === 'tool_use' && this.currentBlockId && this.currentSegmentIdx >= 0) {
310
+ const blockId = this.currentBlockId;
311
+ const seg = this.segments[this.currentSegmentIdx];
312
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
313
+ if (seg.type === 'subagent') {
314
+ // Finalize label from complete input
315
+ const extracted = extractAgentLabel(inputJson);
316
+ if (extracted.label) {
317
+ seg.label = extracted.label;
318
+ seg.labelField = extracted.field;
319
+ }
320
+ // Mark as dispatched (input complete, waiting for tool_result)
321
+ seg.status = 'dispatched';
322
+ seg.content = renderSegment(seg);
323
+ this.requestRender();
324
+ }
325
+ else if (seg.type === 'tool') {
326
+ // Finalize preview from complete input
327
+ const summary = extractToolInputSummary(seg.toolName, inputJson, 80);
328
+ if (summary)
329
+ seg.inputPreview = summary;
330
+ seg.content = renderSegment(seg);
331
+ this.requestRender();
332
+ }
333
+ }
334
+ else if (this.currentBlockType === 'image' && this.imageBase64Buffer) {
335
+ await this.sendImage();
336
+ }
337
+ this.currentBlockType = null;
338
+ this.currentBlockId = null;
339
+ this.currentSegmentIdx = -1;
340
+ }
341
+ // ── Public tool resolution API ──
342
+ /** Resolve a tool indicator with success/failure status. Called by bridge on tool_result. */
343
+ resolveToolMessage(blockId, isError, errorMessage, resultContent, toolUseResult) {
344
+ const segIdx = this.segments.findIndex(s => (s.type === 'tool' || s.type === 'subagent') && s.id === blockId);
345
+ if (segIdx < 0)
346
+ return;
347
+ const seg = this.segments[segIdx];
348
+ if (seg.type === 'subagent') {
349
+ // Sub-agent spawn confirmation β†’ mark as dispatched/waiting
350
+ const isSpawnConfirmation = toolUseResult?.status === 'teammate_spawned' ||
351
+ (typeof resultContent === 'string' && (/agent_id:\s*\S+@\S+/.test(resultContent) || /[Ss]pawned\s+successfully/i.test(resultContent)));
352
+ if (isSpawnConfirmation) {
353
+ seg.status = 'dispatched';
354
+ seg.content = renderSegment(seg);
355
+ this.requestRender();
356
+ return;
357
+ }
358
+ // Tool result (synchronous completion) β†’ mark completed
359
+ seg.status = 'completed';
360
+ seg.content = renderSegment(seg);
361
+ this.requestRender();
362
+ return;
363
+ }
364
+ if (seg.type === 'tool') {
365
+ // Cancel the hide timer β€” tool is now visible in its final state.
366
+ // If it resolved within 500ms the timer was still running; cancelling it means
367
+ // the ⚑ pending indicator was never shown and we render directly as βœ….
368
+ const hideTimer = this.toolHideTimers.get(blockId);
369
+ if (hideTimer !== undefined) {
370
+ clearTimeout(hideTimer);
371
+ this.toolHideTimers.delete(blockId);
372
+ }
373
+ // MCP media tools: remove segment on success (media itself is the result)
374
+ if (StreamAccumulator.MCP_MEDIA_TOOLS.has(seg.toolName) && !isError) {
375
+ this.segments.splice(segIdx, 1);
376
+ this.requestRender();
377
+ return;
378
+ }
379
+ const elapsed = ((Date.now() - seg.startTime) / 1000).toFixed(1) + 's';
380
+ seg.elapsed = elapsed;
381
+ if (isError) {
382
+ seg.status = 'error';
383
+ }
384
+ else {
385
+ seg.status = 'resolved';
386
+ // Finalize input preview from buffer if not set
387
+ const inputJson = this.toolInputBuffers.get(blockId) ?? '';
388
+ if (!seg.inputPreview) {
389
+ const summary = extractToolInputSummary(seg.toolName, inputJson);
390
+ if (summary)
391
+ seg.inputPreview = summary;
392
+ }
393
+ // Compute result stat
394
+ const resultStat = extractToolResultStat(seg.toolName, resultContent, toolUseResult);
395
+ if (resultStat)
396
+ seg.resultStat = resultStat;
397
+ }
398
+ this.toolInputBuffers.delete(blockId);
399
+ seg.content = renderSegment(seg);
400
+ this.requestRender();
401
+ }
402
+ }
403
+ /** Update a sub-agent segment status (called by bridge on task_started/progress/completed). */
404
+ updateSubAgentSegment(blockId, status, label) {
405
+ const seg = this.segments.find(s => s.type === 'subagent' && s.id === blockId);
406
+ if (!seg)
407
+ return;
408
+ if (seg.status === 'completed')
409
+ return; // don't downgrade
410
+ seg.status = status;
411
+ if (label && label.length > seg.label.length)
412
+ seg.label = label;
413
+ seg.content = renderSegment(seg);
414
+ this.requestRender();
415
+ }
416
+ /** Append a high-signal progress line to a sub-agent segment (called by bridge on task_progress). */
417
+ appendSubAgentProgress(blockId, description, lastToolName) {
418
+ const seg = this.segments.find(s => s.type === 'subagent' && s.id === blockId);
419
+ if (!seg || seg.status === 'completed')
420
+ return;
421
+ const line = formatProgressLine(description, lastToolName);
422
+ if (!line)
423
+ return;
424
+ seg.progressLines.push(line);
425
+ if (seg.progressLines.length > MAX_PROGRESS_LINES)
426
+ seg.progressLines.shift();
427
+ seg.content = renderSegment(seg);
428
+ this.requestRender();
429
+ }
430
+ static MCP_MEDIA_TOOLS = new Set(['mcp__tgcc__send_image', 'mcp__tgcc__send_file', 'mcp__tgcc__send_voice']);
431
+ // ── Rendering ──
432
+ /** Render all segments to one HTML string. */
433
+ renderHtml() {
434
+ const parts = this.segments
435
+ .map(s => {
436
+ // Hide pending tool segments until 500ms has elapsed β€” fast tools go directly to resolved state
437
+ if (s.type === 'tool' && s.status === 'pending' && this.toolHideTimers.has(s.id))
438
+ return '';
439
+ return s.content;
440
+ })
441
+ .filter(c => c.length > 0);
442
+ return parts.join('\n') || '…';
443
+ }
444
+ /** Force any pending timer to fire immediately and await the send queue.
445
+ * Bypasses the first-send gate (like finalize). Useful in tests. */
446
+ async flush() {
447
+ this.firstSendReady = true;
448
+ this.clearFirstSendTimer();
449
+ if (this.flushTimer) {
450
+ clearTimeout(this.flushTimer);
451
+ this.flushTimer = null;
452
+ this.flushRender();
453
+ }
454
+ else if (this.dirty && !this.flushInFlight && !this.sealed) {
455
+ this.flushRender();
456
+ }
457
+ await this.sendQueue;
458
+ }
459
+ /** Mark dirty and schedule a throttled flush. The single entry point for all renders.
460
+ * Data in β†’ dirty flag β†’ throttled flush β†’ TG edit. One path, no re-entrant loops. */
461
+ flushInFlight = false;
462
+ requestRender() {
463
+ this.dirty = true;
464
+ // Don't schedule a new flush while one is in-flight (waiting on sendQueue).
465
+ // The in-flight flush will reschedule after the edit completes.
466
+ if (!this.flushTimer && !this.flushInFlight) {
467
+ const elapsed = Date.now() - this.lastEditTime;
468
+ const delay = Math.max(0, this.editIntervalMs - elapsed);
469
+ this.flushTimer = setTimeout(() => this.flushRender(), delay);
470
+ }
471
+ }
472
+ /** Timer callback: consumes dirty flag and chains one _doSendOrEdit onto sendQueue. */
473
+ flushRender() {
474
+ this.flushTimer = null;
475
+ if (!this.dirty || this.sealed)
476
+ return;
477
+ // Gate: delay first TG message until real text content arrives or 2s have passed.
478
+ if (!this.tgMessageId && !this.checkFirstSendReady()) {
479
+ if (!this.firstSendTimer) {
480
+ const remaining = Math.max(0, 2000 - (Date.now() - this.turnStartTime));
481
+ this.firstSendTimer = setTimeout(() => {
482
+ this.firstSendTimer = null;
483
+ this.firstSendReady = true;
484
+ this.requestRender();
485
+ }, remaining);
486
+ }
487
+ // dirty stays true; requestRender() will re-schedule when timer fires
488
+ return;
489
+ }
490
+ this.dirty = false;
491
+ this.flushInFlight = true;
492
+ const html = this.renderHtml();
493
+ const afterFlush = () => {
494
+ this.flushInFlight = false;
495
+ // If new data arrived while we were in-flight, schedule another flush
496
+ if (this.dirty && !this.sealed) {
497
+ this.requestRender();
498
+ }
499
+ };
500
+ if (html.length > this.splitThreshold) {
501
+ this.sendQueue = this.sendQueue
502
+ .then(() => this.splitMessage())
503
+ .then(afterFlush, (err) => { afterFlush(); this.logger?.error?.({ err }, 'flushRender splitMessage failed'); });
504
+ }
505
+ else {
506
+ this.sendQueue = this.sendQueue
507
+ .then(() => this._doSendOrEdit(html || '…'))
508
+ .then(afterFlush, (err) => { afterFlush(); this.logger?.error?.({ err }, 'flushRender failed'); });
509
+ }
510
+ }
511
+ /** Split oversized message β€” called from within the sendQueue chain, uses _doSendOrEdit directly. */
512
+ async splitMessage() {
513
+ // Find text segments to split on
514
+ let totalLen = 0;
515
+ let splitSegIdx = -1;
516
+ for (let i = 0; i < this.segments.length; i++) {
517
+ const seg = this.segments[i];
518
+ totalLen += seg.content.length + 1;
519
+ if (totalLen > this.splitThreshold && splitSegIdx < 0) {
520
+ splitSegIdx = i;
521
+ }
522
+ }
523
+ if (splitSegIdx <= 0) {
524
+ // Can't split cleanly β€” truncate the HTML
525
+ const html = this.renderHtml().slice(0, this.splitThreshold);
526
+ await this._doSendOrEdit(html);
527
+ return;
528
+ }
529
+ // Render first part, start new message with remainder
530
+ const firstSegs = this.segments.slice(0, splitSegIdx);
531
+ const restSegs = this.segments.slice(splitSegIdx);
532
+ const firstHtml = firstSegs.map(s => s.content).filter(Boolean).join('\n');
533
+ await this._doSendOrEdit(firstHtml);
534
+ // Start a new message for remainder
535
+ this.tgMessageId = null;
536
+ this.segments = restSegs;
537
+ const restHtml = this.renderHtml();
538
+ await this._doSendOrEdit(restHtml);
539
+ }
540
+ checkFirstSendReady() {
541
+ if (this.firstSendReady)
542
+ return true;
543
+ const textChars = this.segments
544
+ .filter((s) => s.type === 'text')
545
+ .reduce((sum, s) => sum + s.rawText.length, 0);
546
+ if (textChars >= 200 || Date.now() - this.turnStartTime >= 2000) {
547
+ this.firstSendReady = true;
548
+ this.clearFirstSendTimer();
549
+ return true;
550
+ }
551
+ return false;
552
+ }
553
+ clearFirstSendTimer() {
554
+ if (this.firstSendTimer) {
555
+ clearTimeout(this.firstSendTimer);
556
+ this.firstSendTimer = null;
557
+ }
558
+ }
559
+ /** Force-split when a text segment exceeds 50KB */
560
+ async forceSplitText(seg) {
561
+ const maxChars = 40_000;
562
+ const splitAt = findSplitPoint(seg.rawText, maxChars);
563
+ const firstPart = seg.rawText.slice(0, splitAt);
564
+ const remainder = seg.rawText.slice(splitAt);
565
+ // Replace the current text segment with truncated first part
566
+ seg.rawText = firstPart;
567
+ seg.content = renderSegment(seg);
568
+ await this.sendOrEdit(this.renderHtml());
569
+ // Start new message for remainder
570
+ this.tgMessageId = null;
571
+ const newSeg = { type: 'text', rawText: remainder, content: '' };
572
+ newSeg.content = renderSegment(newSeg);
573
+ this.segments = [newSeg];
574
+ this.currentSegmentIdx = 0;
575
+ await this.sendOrEdit(this.renderHtml());
576
+ }
577
+ async finalize() {
578
+ if (this.sealed)
579
+ return; // already finalized β€” guard against double-call (result + exit)
580
+ // Cancel any pending flush β€” we take over from here
581
+ this.clearFlushTimer();
582
+ // Ensure first send is unblocked β€” finalize is the last chance to send anything
583
+ this.firstSendReady = true;
584
+ this.clearFirstSendTimer();
585
+ // Append usage footer segment
586
+ if (this.turnUsage) {
587
+ const usageHtml = formatUsageFooter(this.turnUsage, this.turnUsage.model);
588
+ const seg = { type: 'usage', content: `<blockquote>πŸ“Š ${usageHtml}</blockquote>` };
589
+ this.segments.push(seg);
590
+ }
591
+ // Final render β€” chain directly onto sendQueue so it runs after any in-flight edits
592
+ const html = this.renderHtml();
593
+ if (html && html !== '…') {
594
+ this.sendQueue = this.sendQueue
595
+ .then(() => this._doSendOrEdit(html))
596
+ .catch(err => {
597
+ this.logger?.error?.({ err }, 'finalize failed');
598
+ this.onError?.(err, 'Failed to send/edit message');
599
+ });
600
+ await this.sendQueue;
601
+ }
602
+ this.sealed = true;
603
+ }
184
604
  // ── TG message management ──
185
- /** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
186
- async sendOrEdit(text, rawHtml = false) {
187
- this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml)).catch(err => {
605
+ async sendOrEdit(html) {
606
+ const safeHtml = html || '…';
607
+ this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(safeHtml)).catch(err => {
188
608
  this.logger?.error?.({ err }, 'sendOrEdit failed');
189
609
  this.onError?.(err, 'Failed to send/edit message');
190
610
  });
191
611
  return this.sendQueue;
192
612
  }
193
- async _doSendOrEdit(text, rawHtml = false) {
194
- let safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
195
- // Guard against HTML that has tags but no visible text content (e.g. "<b></b>")
196
- if (!safeText.replace(/<[^>]*>/g, '').trim())
197
- safeText = '...';
198
- // Update timing BEFORE API call to prevent races
613
+ async _doSendOrEdit(html) {
614
+ let text = html || '…';
615
+ if (!text.replace(/<[^>]*>/g, '').trim())
616
+ text = '…';
199
617
  this.lastEditTime = Date.now();
200
618
  try {
201
619
  if (!this.tgMessageId) {
202
- this.tgMessageId = await this.sender.sendMessage(this.chatId, safeText, 'HTML');
620
+ this.tgMessageId = await this.sender.sendMessage(this.chatId, text, 'HTML');
203
621
  this.messageIds.push(this.tgMessageId);
204
622
  }
205
623
  else {
206
- await this.sender.editMessage(this.chatId, this.tgMessageId, safeText, 'HTML');
624
+ await this.sender.editMessage(this.chatId, this.tgMessageId, text, 'HTML');
207
625
  }
208
626
  }
209
627
  catch (err) {
210
628
  const errorCode = err && typeof err === 'object' && 'error_code' in err
211
629
  ? err.error_code : 0;
212
- // Handle TG rate limit (429) β€” retry
213
630
  if (errorCode === 429) {
214
631
  const retryAfter = err.parameters?.retry_after ?? 5;
215
632
  this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
216
633
  await sleep(retryAfter * 1000);
217
634
  return this._doSendOrEdit(text);
218
635
  }
219
- // Ignore "message is not modified" errors (harmless)
220
636
  if (err instanceof Error && err.message.includes('message is not modified'))
221
637
  return;
222
- // 400 (bad request), 403 (forbidden), and all other errors β€” log and skip, never throw
223
638
  this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit β€” skipping');
224
639
  }
225
640
  }
@@ -232,291 +647,54 @@ export class StreamAccumulator {
232
647
  this.messageIds.push(msgId);
233
648
  }
234
649
  catch (err) {
235
- // Fall back to text indicator on failure
236
650
  this.logger?.error?.({ err }, 'Failed to send image');
237
- this.buffer += '\n[Image could not be sent]';
238
651
  }
239
652
  this.imageBase64Buffer = '';
240
653
  }
241
- /** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
242
- async sendToolIndicator(blockId, toolName) {
243
- const startTime = Date.now();
244
- this.sendQueue = this.sendQueue.then(async () => {
245
- try {
246
- const html = `<blockquote expandable>⚑ ${escapeHtml(toolName)}…</blockquote>`;
247
- const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
248
- this.toolMessages.set(blockId, { msgId, toolName, startTime });
249
- }
250
- catch (err) {
251
- // Tool indicator is non-critical β€” log and continue
252
- this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
253
- }
254
- }).catch(err => {
255
- this.logger?.error?.({ err }, 'sendToolIndicator queue error');
256
- });
257
- return this.sendQueue;
258
- }
259
- /** Update a tool indicator message with input preview once the JSON value is complete. */
260
- toolIndicatorLastSummary = new Map(); // blockId β†’ last rendered summary
261
- async updateToolIndicatorWithInput(blockId) {
262
- const entry = this.toolMessages.get(blockId);
263
- if (!entry)
264
- return;
265
- const inputJson = this.toolInputBuffers.get(blockId) ?? '';
266
- // Only extract from complete JSON (try parse succeeds) or complete regex match
267
- // (the value must have a closing quote to avoid truncated paths)
268
- const summary = extractToolInputSummary(entry.toolName, inputJson, 120, true);
269
- if (!summary)
270
- return; // not enough input yet or value still streaming
271
- // Skip if summary hasn't changed since last edit
272
- if (this.toolIndicatorLastSummary.get(blockId) === summary)
273
- return;
274
- this.toolIndicatorLastSummary.set(blockId, summary);
275
- const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
276
- const html = `<blockquote expandable>⚑ ${escapeHtml(entry.toolName)}…${codeLine}</blockquote>`;
277
- this.sendQueue = this.sendQueue.then(async () => {
278
- try {
279
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
280
- }
281
- catch (err) {
282
- // "message is not modified" or other edit failure β€” non-critical
283
- this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
284
- }
285
- }).catch(err => {
286
- this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
287
- });
288
- return this.sendQueue;
289
- }
290
- /** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
291
- async resolveToolMessage(blockId, isError, errorMessage, resultContent, toolUseResult) {
292
- const entry = this.toolMessages.get(blockId);
293
- if (!entry)
294
- return;
295
- const { msgId, toolName, startTime } = entry;
296
- const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
297
- const inputJson = this.toolInputBuffers.get(blockId) ?? '';
298
- const summary = extractToolInputSummary(toolName, inputJson);
299
- const resultStat = extractToolResultStat(toolName, resultContent, toolUseResult);
300
- const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
301
- const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
302
- const icon = isError ? '❌' : 'βœ…';
303
- const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
304
- // Clean up input buffer
305
- this.toolInputBuffers.delete(blockId);
306
- this.toolIndicatorLastSummary.delete(blockId);
307
- this.sendQueue = this.sendQueue.then(async () => {
308
- try {
309
- await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
310
- }
311
- catch (err) {
312
- // Edit failure on resolve β€” non-critical
313
- this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
314
- }
315
- }).catch(err => {
316
- this.logger?.error?.({ err }, 'resolveToolMessage queue error');
317
- });
318
- return this.sendQueue;
319
- }
320
- /** Edit a specific tool indicator message by block ID. */
321
- async editToolMessage(blockId, html) {
322
- const entry = this.toolMessages.get(blockId);
323
- if (!entry)
324
- return;
325
- this.sendQueue = this.sendQueue.then(async () => {
326
- try {
327
- await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
328
- }
329
- catch (err) {
330
- // Edit failure β€” non-critical
331
- this.logger?.debug?.({ err }, 'Failed to edit tool message');
332
- }
333
- }).catch(err => {
334
- this.logger?.error?.({ err }, 'editToolMessage queue error');
335
- });
336
- return this.sendQueue;
337
- }
338
- /** Delete a specific tool indicator message by block ID. */
339
- async deleteToolMessage(blockId) {
340
- const entry = this.toolMessages.get(blockId);
341
- if (!entry)
342
- return;
343
- this.toolMessages.delete(blockId);
344
- this.sendQueue = this.sendQueue.then(async () => {
345
- try {
346
- if (this.sender.deleteMessage) {
347
- await this.sender.deleteMessage(this.chatId, entry.msgId);
348
- }
349
- }
350
- catch (err) {
351
- // Delete failure β€” non-critical
352
- this.logger?.debug?.({ err }, 'Failed to delete tool message');
353
- }
354
- }).catch(err => {
355
- this.logger?.error?.({ err }, 'deleteToolMessage queue error');
356
- });
357
- return this.sendQueue;
358
- }
359
- /** Delete all tool indicator messages. */
360
- async deleteAllToolMessages() {
361
- const ids = [...this.toolMessages.values()];
362
- this.toolMessages.clear();
363
- if (!this.sender.deleteMessage)
364
- return;
365
- this.sendQueue = this.sendQueue.then(async () => {
366
- for (const { msgId } of ids) {
367
- try {
368
- await this.sender.deleteMessage(this.chatId, msgId);
369
- }
370
- catch (err) {
371
- // Delete failure β€” non-critical
372
- this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
373
- }
374
- }
375
- }).catch(err => {
376
- this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
377
- });
378
- return this.sendQueue;
379
- }
380
- async throttledEdit() {
381
- const now = Date.now();
382
- const elapsed = now - this.lastEditTime;
383
- if (elapsed >= this.editIntervalMs) {
384
- // Enough time passed β€” edit now
385
- await this.doEdit();
386
- }
387
- else if (!this.editTimer) {
388
- // Schedule an edit
389
- const delay = this.editIntervalMs - elapsed;
390
- this.editTimer = setTimeout(async () => {
391
- this.editTimer = null;
392
- if (!this.finished) {
393
- await this.doEdit();
394
- }
395
- }, delay);
396
- }
397
- }
398
- /** Build the full message text including thinking blockquote prefix and usage footer.
399
- * Returns { text, hasHtmlSuffix } β€” caller must pass rawHtml=true when hasHtmlSuffix is set
400
- * because the footer contains pre-formatted HTML (<i> tags).
401
- */
402
- buildFullText(includeSuffix = false) {
403
- let text = '';
404
- if (this.thinkingBuffer) {
405
- const thinkingPreview = this.thinkingBuffer.length > 1024
406
- ? this.thinkingBuffer.slice(0, 1024) + '…'
407
- : this.thinkingBuffer;
408
- text += `<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>\n`;
409
- }
410
- // Convert markdown buffer to HTML-safe text
411
- text += makeHtmlSafe(this.buffer);
412
- if (includeSuffix && this.turnUsage) {
413
- text += '\n' + formatUsageFooter(this.turnUsage, this.turnUsage.model);
414
- }
415
- return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
416
- }
417
- async doEdit() {
418
- if (!this.buffer)
419
- return;
420
- const { text, hasHtmlSuffix } = this.buildFullText();
421
- // Check if we need to split
422
- if (text.length > this.splitThreshold) {
423
- await this.splitMessage();
424
- return;
425
- }
426
- await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
427
- }
428
- async splitMessage() {
429
- // Find a good split point near the threshold
430
- const splitAt = findSplitPoint(this.buffer, this.splitThreshold);
431
- const firstPart = this.buffer.slice(0, splitAt);
432
- const remainder = this.buffer.slice(splitAt);
433
- // Finalize current message with first part
434
- await this.sendOrEdit(makeHtmlSafe(firstPart), true);
435
- // Start a new message for remainder
436
- this.tgMessageId = null;
437
- this.buffer = remainder;
438
- if (this.buffer) {
439
- await this.sendOrEdit(makeHtmlSafe(this.buffer), true);
440
- }
441
- }
442
- /** Emergency split/truncation when buffer exceeds 50KB absolute limit */
443
- async forceSplitOrTruncate() {
444
- const maxChars = 50_000;
445
- if (this.buffer.length > maxChars) {
446
- // Split at a reasonable point within the 50KB limit
447
- const splitAt = findSplitPoint(this.buffer, maxChars - 200); // Leave some margin
448
- const firstPart = this.buffer.slice(0, splitAt);
449
- const remainder = this.buffer.slice(splitAt);
450
- // Finalize current message with first part + truncation notice
451
- const truncationNotice = '\n\n<i>[Output truncated - buffer limit exceeded]</i>';
452
- await this.sendOrEdit(makeHtmlSafe(firstPart) + truncationNotice, true);
453
- // Start a new message for remainder (up to another 50KB)
454
- this.tgMessageId = null;
455
- this.buffer = remainder.length > maxChars
456
- ? remainder.slice(0, maxChars - 100) + '...'
457
- : remainder;
458
- if (this.buffer) {
459
- await this.sendOrEdit(makeHtmlSafe(this.buffer), true);
460
- }
461
- }
462
- }
463
- async finalize() {
464
- this.finished = true;
465
- // Clear any pending edit timer
466
- if (this.editTimer) {
467
- clearTimeout(this.editTimer);
468
- this.editTimer = null;
469
- }
470
- if (this.buffer) {
471
- // Final edit with complete text including thinking blockquote and usage footer
472
- const { text } = this.buildFullText(true);
473
- await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
474
- }
475
- else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
476
- // Only thinking happened, no text β€” show thinking as expandable blockquote
477
- const thinkingPreview = this.thinkingBuffer.length > 1024
478
- ? this.thinkingBuffer.slice(0, 1024) + '…'
479
- : this.thinkingBuffer;
480
- await this.sendOrEdit(`<blockquote expandable>πŸ’­ Thinking\n${markdownToTelegramHtml(thinkingPreview)}</blockquote>`, true);
481
- }
482
- }
483
- clearEditTimer() {
484
- if (this.editTimer) {
485
- clearTimeout(this.editTimer);
486
- this.editTimer = null;
487
- }
488
- }
489
- /** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
490
- * toolMessages persists across resets β€” they are independent of the text accumulator. */
654
+ /** Soft reset: clear per-API-call transient state. Segments and tgMessageId persist across
655
+ * tool-use loop iterations within the same turn. */
491
656
  softReset() {
492
- this.buffer = '';
493
- this.thinkingBuffer = '';
494
- this.imageBase64Buffer = '';
495
657
  this.currentBlockType = null;
496
- this.currentToolBlockId = null;
497
- this.lastEditTime = 0;
498
- this.thinkingIndicatorShown = false;
499
- this.finished = false;
658
+ this.currentBlockId = null;
659
+ this.currentSegmentIdx = -1;
660
+ this.sealed = false;
500
661
  this.turnUsage = null;
501
662
  this._lastMsgStartCtx = null;
502
- this.clearEditTimer();
663
+ this.clearFlushTimer();
664
+ for (const t of this.toolHideTimers.values())
665
+ clearTimeout(t);
666
+ this.toolHideTimers.clear();
503
667
  }
504
- /** Full reset: also clears tgMessageId (next send creates a new message).
505
- * Chains on the existing sendQueue so any pending finalize() edits complete first.
506
- * toolMessages persists β€” they are independent fire-and-forget messages. */
668
+ /** Full reset: clear everything for a new turn. */
507
669
  reset() {
508
670
  const prevQueue = this.sendQueue;
509
671
  this.softReset();
672
+ this.segments = [];
510
673
  this.tgMessageId = null;
511
674
  this.messageIds = [];
512
- this.sendQueue = prevQueue.catch(() => { }); // swallow errors from prev turn
675
+ this.toolInputBuffers.clear();
676
+ this.imageBase64Buffer = '';
677
+ this.lastEditTime = 0;
678
+ this.dirty = false;
679
+ this.flushInFlight = false;
680
+ this.sendQueue = prevQueue.catch(() => { });
681
+ this.turnStartTime = Date.now();
682
+ this.firstSendReady = false;
683
+ this.clearFirstSendTimer();
684
+ for (const t of this.toolHideTimers.values())
685
+ clearTimeout(t);
686
+ this.toolHideTimers.clear();
687
+ }
688
+ clearFlushTimer() {
689
+ if (this.flushTimer) {
690
+ clearTimeout(this.flushTimer);
691
+ this.flushTimer = null;
692
+ }
513
693
  }
514
694
  }
515
695
  // ── Helpers ──
516
- /** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
517
696
  /** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts β†’ KYO/src/foo.ts */
518
697
  function shortenPath(p) {
519
- // Strip common prefixes
520
698
  return p
521
699
  .replace(/^\/home\/[^/]+\/Botverse\//, '')
522
700
  .replace(/^\/home\/[^/]+\/Projects\//, '')
@@ -525,7 +703,6 @@ function shortenPath(p) {
525
703
  function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
526
704
  if (!inputJson)
527
705
  return null;
528
- // Determine which field(s) to look for based on tool name
529
706
  const fieldsByTool = {
530
707
  Bash: ['command'],
531
708
  Read: ['file_path', 'path'],
@@ -535,22 +712,19 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
535
712
  Search: ['pattern', 'query'],
536
713
  Grep: ['pattern', 'query'],
537
714
  Glob: ['pattern'],
538
- TodoWrite: [], // handled specially below
539
- TaskOutput: [], // handled specially below
715
+ TodoWrite: [],
716
+ TaskOutput: [],
540
717
  };
541
718
  const skipTools = new Set(['TodoRead']);
542
719
  if (skipTools.has(toolName))
543
720
  return null;
544
721
  const fields = fieldsByTool[toolName];
545
722
  const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
546
- // Try parsing complete JSON first
547
723
  try {
548
724
  const parsed = JSON.parse(inputJson);
549
- // TaskOutput: show task ID compactly
550
725
  if (toolName === 'TaskOutput' && parsed.task_id) {
551
726
  return `collecting result Β· ${String(parsed.task_id).slice(0, 7)}`;
552
727
  }
553
- // TodoWrite: show in-progress item or summary
554
728
  if (toolName === 'TodoWrite' && Array.isArray(parsed.todos)) {
555
729
  const todos = parsed.todos;
556
730
  const inProgress = todos.find(t => t.status === 'in_progress');
@@ -562,6 +736,14 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
562
736
  const combined = prefix + label;
563
737
  return combined.length > maxLen ? combined.slice(0, maxLen) + '…' : combined;
564
738
  }
739
+ if (toolName === 'Grep' || toolName === 'Search') {
740
+ const pattern = (parsed.pattern || parsed.query || '').trim();
741
+ const searchPath = (parsed.path || parsed.glob || '').trim();
742
+ if (pattern) {
743
+ const combined = searchPath ? `${pattern} in ${shortenPath(searchPath)}` : pattern;
744
+ return combined.length > maxLen ? combined.slice(0, maxLen) + '…' : combined;
745
+ }
746
+ }
565
747
  if (fields) {
566
748
  for (const f of fields) {
567
749
  if (typeof parsed[f] === 'string' && parsed[f].trim()) {
@@ -572,7 +754,6 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
572
754
  }
573
755
  }
574
756
  }
575
- // Default: first string value
576
757
  for (const val of Object.values(parsed)) {
577
758
  if (typeof val === 'string' && val.trim()) {
578
759
  const v = val.trim();
@@ -583,11 +764,10 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
583
764
  }
584
765
  catch {
585
766
  if (requireComplete)
586
- return null; // Only use fully-parsed JSON when requireComplete
587
- // Partial JSON β€” regex extraction (only used for final resolve, not live preview)
767
+ return null;
588
768
  const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
589
769
  for (const key of targetFields) {
590
- const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
770
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i');
591
771
  const m = inputJson.match(re);
592
772
  if (m?.[1]) {
593
773
  let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
@@ -599,9 +779,7 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
599
779
  return null;
600
780
  }
601
781
  }
602
- /** Extract a compact stat from a tool result for display in the indicator. */
603
782
  function extractToolResultStat(toolName, content, toolUseResult) {
604
- // For Edit/Write: use structured patch data if available
605
783
  if (toolUseResult && (toolName === 'Edit' || toolName === 'MultiEdit')) {
606
784
  const patches = toolUseResult.structuredPatch;
607
785
  if (patches?.length) {
@@ -628,17 +806,13 @@ function extractToolResultStat(toolName, content, toolUseResult) {
628
806
  }
629
807
  if (toolUseResult && toolName === 'Write') {
630
808
  const c = toolUseResult.content;
631
- if (c) {
632
- const lines = c.split('\n').length;
633
- return `${lines} lines`;
634
- }
809
+ if (c)
810
+ return `${c.split('\n').length} lines`;
635
811
  }
636
812
  if (!content)
637
813
  return '';
638
814
  const first = content.split('\n')[0].trim();
639
- // Skip generic "The file X has been updated/created" messages
640
815
  if (/^(The file |File created|Successfully)/.test(first)) {
641
- // Try to extract something useful anyway
642
816
  const lines = content.match(/(\d+)\s*lines?/i);
643
817
  if (lines)
644
818
  return `${lines[1]} lines`;
@@ -691,25 +865,49 @@ function extractToolResultStat(toolName, content, toolUseResult) {
691
865
  }
692
866
  }
693
867
  function findSplitPoint(text, threshold) {
694
- // Try to split at paragraph break
695
868
  const paragraphBreak = text.lastIndexOf('\n\n', threshold);
696
869
  if (paragraphBreak > threshold * 0.5)
697
870
  return paragraphBreak;
698
- // Try to split at line break
699
871
  const lineBreak = text.lastIndexOf('\n', threshold);
700
872
  if (lineBreak > threshold * 0.5)
701
873
  return lineBreak;
702
- // Try to split at sentence end
703
874
  const sentenceEnd = text.lastIndexOf('. ', threshold);
704
875
  if (sentenceEnd > threshold * 0.5)
705
876
  return sentenceEnd + 2;
706
- // Fall back to threshold
707
877
  return threshold;
708
878
  }
709
879
  function sleep(ms) {
710
880
  return new Promise(resolve => setTimeout(resolve, ms));
711
881
  }
712
- /** Format token count as human-readable: 1234 β†’ "1.2k", 500 β†’ "500" */
882
+ function formatElapsed(ms) {
883
+ const s = Math.floor(ms / 1000);
884
+ const m = Math.floor(s / 60);
885
+ if (m > 0)
886
+ return `${m}m ${s % 60}s`;
887
+ return `${s}s`;
888
+ }
889
+ const MAX_PROGRESS_LINES = 5;
890
+ /** Format a task_progress event into a single HTML-safe progress line.
891
+ * Returns null if the event has no useful display content. */
892
+ export function formatProgressLine(description, lastToolName) {
893
+ const desc = description?.trim();
894
+ if (!desc)
895
+ return null;
896
+ const lower = desc.toLowerCase();
897
+ const tool = lastToolName?.toLowerCase() ?? '';
898
+ let emoji = 'πŸ“‹';
899
+ if (tool === 'bash' && /build|compile|npm run|pnpm|yarn|make/.test(lower)) {
900
+ emoji = 'πŸ”¨';
901
+ }
902
+ else if (tool === 'bash' && /git commit|git push/.test(lower)) {
903
+ emoji = 'πŸ“';
904
+ }
905
+ else if (/context.*%|%.*context|\d{2,3}%/.test(lower)) {
906
+ emoji = '🧠';
907
+ }
908
+ const truncated = desc.length > 60 ? desc.slice(0, 60) + '…' : desc;
909
+ return `${emoji} ${escapeHtml(truncated)}`;
910
+ }
713
911
  function formatTokens(n) {
714
912
  if (n >= 1000)
715
913
  return (n / 1000).toFixed(1) + 'k';
@@ -717,8 +915,6 @@ function formatTokens(n) {
717
915
  }
718
916
  /** Format usage stats as an HTML italic footer line */
719
917
  export function formatUsageFooter(usage, _model) {
720
- // Use per-API-call ctx tokens (from message_start) for context % β€” these are bounded by the
721
- // context window. Fall back to cumulative result event tokens only if ctx tokens unavailable.
722
918
  const ctxInput = usage.ctxInputTokens ?? usage.inputTokens;
723
919
  const ctxRead = usage.ctxCacheReadTokens ?? usage.cacheReadTokens;
724
920
  const ctxCreation = usage.ctxCacheCreationTokens ?? usage.cacheCreationTokens;
@@ -727,7 +923,7 @@ export function formatUsageFooter(usage, _model) {
727
923
  const ctxPct = Math.round(totalCtx / CONTEXT_WINDOW * 100);
728
924
  const overLimit = ctxPct > 90;
729
925
  const parts = [
730
- `↩️ ${formatTokens(usage.inputTokens)} in`,
926
+ `${formatTokens(usage.inputTokens)} in`,
731
927
  `${formatTokens(usage.outputTokens)} out`,
732
928
  ];
733
929
  if (usage.costUsd != null) {
@@ -738,17 +934,14 @@ export function formatUsageFooter(usage, _model) {
738
934
  }
739
935
  // ── Sub-agent detection patterns ──
740
936
  const CC_SUB_AGENT_TOOLS = new Set([
741
- 'Task', // Primary CC spawning tool
742
- 'dispatch_agent', // Legacy/alternative tool
743
- 'create_agent', // Test compatibility
744
- 'AgentRunner' // Test compatibility
937
+ 'Task',
938
+ 'dispatch_agent',
939
+ 'create_agent',
940
+ 'AgentRunner'
745
941
  ]);
746
942
  export function isSubAgentTool(toolName) {
747
943
  return CC_SUB_AGENT_TOOLS.has(toolName);
748
944
  }
749
- /** Extract a human-readable summary from partial/complete JSON tool input.
750
- * Looks for prompt, task, command, description fields β€” returns the first found, truncated.
751
- */
752
945
  export function extractSubAgentSummary(jsonInput, maxLen = 150) {
753
946
  try {
754
947
  const parsed = JSON.parse(jsonInput);
@@ -758,7 +951,6 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
758
951
  }
759
952
  }
760
953
  catch {
761
- // Partial JSON β€” try regex extraction for common patterns
762
954
  for (const key of ['prompt', 'task', 'command', 'description', 'message']) {
763
955
  const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'i');
764
956
  const m = jsonInput.match(re);
@@ -770,22 +962,15 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
770
962
  }
771
963
  return '';
772
964
  }
773
- /** Label fields in priority order (index 0 = highest priority). */
774
965
  const LABEL_FIELDS = ['name', 'description', 'subagent_type', 'team_name'];
775
- /** Priority index for a label source field. Lower = better. */
776
966
  export function labelFieldPriority(field) {
777
967
  if (!field)
778
- return LABEL_FIELDS.length + 1; // worst
968
+ return LABEL_FIELDS.length + 1;
779
969
  const idx = LABEL_FIELDS.indexOf(field);
780
970
  return idx >= 0 ? idx : LABEL_FIELDS.length;
781
971
  }
782
- /**
783
- * Extract a human-readable label for a sub-agent from its JSON tool input.
784
- * Returns { label, field } so callers can track priority and upgrade labels
785
- * when higher-priority fields become available during streaming.
786
- */
787
972
  export function extractAgentLabel(jsonInput) {
788
- const summaryField = 'prompt'; // last resort β€” first line of prompt
973
+ const summaryField = 'prompt';
789
974
  try {
790
975
  const parsed = JSON.parse(jsonInput);
791
976
  for (const key of LABEL_FIELDS) {
@@ -802,13 +987,10 @@ export function extractAgentLabel(jsonInput) {
802
987
  return { label: '', field: null };
803
988
  }
804
989
  catch {
805
- // JSON incomplete during streaming β€” extract first complete field value
806
990
  const result = extractFieldFromPartialJsonWithField(jsonInput, LABEL_FIELDS);
807
991
  return result ?? { label: '', field: null };
808
992
  }
809
993
  }
810
- /** Extract the first complete string value for any of the given keys from partial JSON.
811
- * Returns { label, field } so callers know which field matched. */
812
994
  function extractFieldFromPartialJsonWithField(input, keys) {
813
995
  for (const key of keys) {
814
996
  const idx = input.indexOf(`"${key}"`);
@@ -821,7 +1003,6 @@ function extractFieldFromPartialJsonWithField(input, keys) {
821
1003
  const afterColon = afterKey.slice(colonIdx + 1).trimStart();
822
1004
  if (!afterColon.startsWith('"'))
823
1005
  continue;
824
- // Walk the string handling escapes
825
1006
  let i = 1, value = '';
826
1007
  while (i < afterColon.length) {
827
1008
  if (afterColon[i] === '\\' && i + 1 < afterColon.length) {
@@ -844,9 +1025,9 @@ function extractFieldFromPartialJsonWithField(input, keys) {
844
1025
  export class SubAgentTracker {
845
1026
  chatId;
846
1027
  sender;
847
- agents = new Map(); // toolUseId β†’ info
848
- blockToAgent = new Map(); // blockIndex β†’ toolUseId
849
- consolidatedAgentMsgId = null; // shared TG message for all sub-agents
1028
+ agents = new Map();
1029
+ blockToAgent = new Map();
1030
+ standaloneMsgId = null; // post-turn standalone status bubble
850
1031
  sendQueue = Promise.resolve();
851
1032
  teamName = null;
852
1033
  mailboxPath = null;
@@ -854,6 +1035,9 @@ export class SubAgentTracker {
854
1035
  lastMailboxCount = 0;
855
1036
  onAllReported = null;
856
1037
  hasPendingFollowUp = false;
1038
+ /** When true, stream events update agent metadata but do NOT create TG messages.
1039
+ * Set to false after the main turn bubble is sealed to allow standalone status bubble. */
1040
+ inTurn = true;
857
1041
  constructor(options) {
858
1042
  this.chatId = options.chatId;
859
1043
  this.sender = options.sender;
@@ -861,32 +1045,41 @@ export class SubAgentTracker {
861
1045
  get activeAgents() {
862
1046
  return [...this.agents.values()];
863
1047
  }
864
- /** Returns true if any sub-agents were tracked in this turn (including completed ones) */
865
1048
  get hadSubAgents() {
866
1049
  return this.agents.size > 0;
867
1050
  }
868
- /** Returns true if any sub-agents are in dispatched state (spawned but no result yet) */
869
1051
  get hasDispatchedAgents() {
870
1052
  return [...this.agents.values()].some(a => a.status === 'dispatched');
871
1053
  }
872
- /** Mark all dispatched agents as completed β€” used when CC reports results
873
- * in its main text response rather than via tool_result events.
874
- */
1054
+ /** Called after the main bubble is sealed. Creates standalone status bubble for any dispatched agents. */
1055
+ async startPostTurnTracking() {
1056
+ this.inTurn = false;
1057
+ if (!this.hasDispatchedAgents)
1058
+ return;
1059
+ // Create the standalone status bubble
1060
+ const html = this.buildStandaloneHtml();
1061
+ this.sendQueue = this.sendQueue.then(async () => {
1062
+ try {
1063
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
1064
+ this.standaloneMsgId = msgId;
1065
+ // Set tgMessageId on all dispatched agents pointing to this standalone bubble
1066
+ for (const info of this.agents.values()) {
1067
+ if (info.status === 'dispatched') {
1068
+ info.tgMessageId = msgId;
1069
+ }
1070
+ }
1071
+ }
1072
+ catch {
1073
+ // Non-critical
1074
+ }
1075
+ }).catch(() => { });
1076
+ await this.sendQueue;
1077
+ }
875
1078
  markDispatchedAsReportedInMain() {
876
1079
  for (const [, info] of this.agents) {
877
- if (info.status !== 'dispatched' || !info.tgMessageId)
1080
+ if (info.status !== 'dispatched')
878
1081
  continue;
879
1082
  info.status = 'completed';
880
- const label = info.label || info.toolName;
881
- const text = `βœ… ${escapeHtml(label)} β€” see main message`;
882
- this.sendQueue = this.sendQueue.then(async () => {
883
- try {
884
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
885
- }
886
- catch (err) {
887
- // Non-critical β€” edit failure on dispatched agent status
888
- }
889
- }).catch(() => { });
890
1083
  }
891
1084
  }
892
1085
  async handleEvent(event) {
@@ -904,12 +1097,8 @@ export class SubAgentTracker {
904
1097
  case 'content_block_stop':
905
1098
  await this.onBlockStop(event);
906
1099
  break;
907
- // NOTE: message_start reset is handled by the bridge (not here)
908
- // so it can check hadSubAgents before clearing state
909
1100
  }
910
1101
  }
911
- /** Handle a tool_result event β€” marks the sub-agent as completed with collapsible result */
912
- /** Set agent metadata from structured tool_use_result */
913
1102
  setAgentMetadata(toolUseId, meta) {
914
1103
  const info = this.agents.get(toolUseId);
915
1104
  if (!info)
@@ -917,13 +1106,13 @@ export class SubAgentTracker {
917
1106
  if (meta.agentName)
918
1107
  info.agentName = meta.agentName;
919
1108
  }
920
- /** Mark an agent as completed externally (e.g. from bridge follow-up) */
921
1109
  markCompleted(toolUseId, _reason) {
922
1110
  const info = this.agents.get(toolUseId);
923
1111
  if (!info || info.status === 'completed')
924
1112
  return;
925
1113
  info.status = 'completed';
926
- // Check if all agents are done
1114
+ if (!this.inTurn)
1115
+ this.updateStandaloneMessage();
927
1116
  const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
928
1117
  if (allDone && this.onAllReported) {
929
1118
  this.onAllReported();
@@ -932,44 +1121,27 @@ export class SubAgentTracker {
932
1121
  }
933
1122
  async handleToolResult(toolUseId, result) {
934
1123
  const info = this.agents.get(toolUseId);
935
- if (!info || !info.tgMessageId)
1124
+ if (!info)
936
1125
  return;
937
- // Detect background agent spawn confirmations β€” keep as dispatched, don't mark completed
938
- // Spawn confirmations contain "agent_id:" and "Spawned" patterns
939
1126
  const isSpawnConfirmation = /agent_id:\s*\S+@\S+/.test(result) || /[Ss]pawned\s+successfully/i.test(result);
940
1127
  if (isSpawnConfirmation) {
941
- // Extract agent name from spawn confirmation for mailbox matching
942
1128
  const nameMatch = result.match(/name:\s*(\S+)/);
943
1129
  if (nameMatch && !info.agentName)
944
1130
  info.agentName = nameMatch[1];
945
1131
  const agentIdMatch = result.match(/agent_id:\s*(\S+)@/);
946
1132
  if (agentIdMatch && !info.agentName)
947
1133
  info.agentName = agentIdMatch[1];
948
- // Mark as dispatched β€” this enables mailbox watching and prevents idle timeout
949
1134
  info.status = 'dispatched';
950
1135
  info.dispatchedAt = Date.now();
951
- this.updateConsolidatedAgentMessage();
1136
+ if (!this.inTurn)
1137
+ this.updateStandaloneMessage();
952
1138
  return;
953
1139
  }
954
- // Skip if already completed (e.g. via mailbox)
955
1140
  if (info.status === 'completed')
956
1141
  return;
957
1142
  info.status = 'completed';
958
- const label = info.label || info.toolName;
959
- // Truncate result for blockquote (TG message limit ~4096 chars)
960
- const maxResultLen = 3500;
961
- const resultText = result.length > maxResultLen ? result.slice(0, maxResultLen) + '…' : result;
962
- // Use expandable blockquote β€” collapsed shows "βœ… label" + first line, tap to expand
963
- const text = `<blockquote expandable>βœ… ${escapeHtml(label)}\n${escapeHtml(resultText)}</blockquote>`;
964
- this.sendQueue = this.sendQueue.then(async () => {
965
- try {
966
- await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
967
- }
968
- catch {
969
- // Edit failure on tool result β€” non-critical
970
- }
971
- }).catch(() => { });
972
- await this.sendQueue;
1143
+ if (!this.inTurn)
1144
+ this.updateStandaloneMessage();
973
1145
  }
974
1146
  async onBlockStart(event) {
975
1147
  if (event.content_block.type !== 'tool_use')
@@ -987,64 +1159,26 @@ export class SubAgentTracker {
987
1159
  labelField: null,
988
1160
  agentName: '',
989
1161
  inputPreview: '',
1162
+ startTime: Date.now(),
990
1163
  dispatchedAt: null,
1164
+ progressLines: [],
991
1165
  };
992
1166
  this.agents.set(block.id, info);
993
1167
  this.blockToAgent.set(event.index, block.id);
994
- // Consolidate all sub-agent indicators into one shared message.
995
- // If a consolidated message already exists, reuse it; otherwise create one.
996
- if (this.consolidatedAgentMsgId) {
997
- info.tgMessageId = this.consolidatedAgentMsgId;
998
- this.updateConsolidatedAgentMessage();
999
- }
1000
- else {
1001
- this.sendQueue = this.sendQueue.then(async () => {
1002
- try {
1003
- const msgId = await this.sender.sendMessage(this.chatId, 'πŸ€– Starting sub-agent…', 'HTML');
1004
- info.tgMessageId = msgId;
1005
- this.consolidatedAgentMsgId = msgId;
1006
- }
1007
- catch {
1008
- // Sub-agent indicator is non-critical
1009
- }
1010
- }).catch(() => { });
1011
- await this.sendQueue;
1012
- }
1013
- }
1014
- /** Build and edit the shared sub-agent status message. */
1015
- updateConsolidatedAgentMessage() {
1016
- if (!this.consolidatedAgentMsgId)
1017
- return;
1018
- const msgId = this.consolidatedAgentMsgId;
1019
- const lines = [];
1020
- for (const info of this.agents.values()) {
1021
- const label = info.label || info.agentName || info.toolName;
1022
- const status = info.status === 'completed' ? 'βœ… Done'
1023
- : info.status === 'dispatched' ? 'Waiting for results…'
1024
- : 'Working…';
1025
- lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1026
- }
1027
- const text = lines.join('\n');
1028
- this.sendQueue = this.sendQueue.then(async () => {
1029
- try {
1030
- await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1031
- }
1032
- catch {
1033
- // Consolidated message edit failure β€” non-critical
1034
- }
1035
- }).catch(() => { });
1168
+ // During the turn, StreamAccumulator renders the sub-agent segment.
1169
+ // Tracker only manages metadata here (no TG message).
1036
1170
  }
1037
1171
  async onInputDelta(event) {
1038
1172
  const toolUseId = this.blockToAgent.get(event.index);
1039
1173
  if (!toolUseId)
1040
1174
  return;
1041
1175
  const info = this.agents.get(toolUseId);
1042
- if (!info || !info.tgMessageId)
1176
+ if (!info)
1043
1177
  return;
1044
1178
  if (info.inputPreview.length < 10_000) {
1045
1179
  info.inputPreview += event.delta.partial_json;
1046
1180
  }
1047
- // Extract agent name from input JSON (used for mailbox matching)
1181
+ // Extract agent name for mailbox matching
1048
1182
  if (!info.agentName) {
1049
1183
  try {
1050
1184
  const parsed = JSON.parse(info.inputPreview);
@@ -1053,19 +1187,16 @@ export class SubAgentTracker {
1053
1187
  }
1054
1188
  }
1055
1189
  catch {
1056
- // Partial JSON β€” try extracting name field
1057
1190
  const nameMatch = info.inputPreview.match(/"name"\s*:\s*"([^"]+)"/);
1058
1191
  if (nameMatch)
1059
1192
  info.agentName = nameMatch[1];
1060
1193
  }
1061
1194
  }
1062
- // Try to extract an agent label β€” keep trying for higher-priority fields
1063
- // (e.g., upgrade from subagent_type "general-purpose" to description "Fix the bug")
1195
+ // Extract label for standalone bubble (used post-turn)
1064
1196
  const extracted = extractAgentLabel(info.inputPreview);
1065
1197
  if (extracted.label && labelFieldPriority(extracted.field) < labelFieldPriority(info.labelField)) {
1066
1198
  info.label = extracted.label;
1067
1199
  info.labelField = extracted.field;
1068
- this.updateConsolidatedAgentMessage();
1069
1200
  }
1070
1201
  }
1071
1202
  async onBlockStop(event) {
@@ -1073,56 +1204,41 @@ export class SubAgentTracker {
1073
1204
  if (!toolUseId)
1074
1205
  return;
1075
1206
  const info = this.agents.get(toolUseId);
1076
- if (!info || !info.tgMessageId)
1207
+ if (!info)
1077
1208
  return;
1078
- // content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
1079
1209
  info.status = 'dispatched';
1080
1210
  info.dispatchedAt = Date.now();
1081
- // Final chance to extract label from complete input (may upgrade to higher-priority field)
1082
1211
  const finalExtracted = extractAgentLabel(info.inputPreview);
1083
1212
  if (finalExtracted.label && labelFieldPriority(finalExtracted.field) < labelFieldPriority(info.labelField)) {
1084
1213
  info.label = finalExtracted.label;
1085
1214
  info.labelField = finalExtracted.field;
1086
1215
  }
1087
- this.updateConsolidatedAgentMessage();
1088
- // Start elapsed timer β€” update every 15s to show progress
1089
1216
  }
1090
- /** Start a periodic timer that edits the message with elapsed time */
1091
- /** Set callback invoked when ALL dispatched sub-agents have mailbox results. */
1092
1217
  setOnAllReported(cb) {
1093
1218
  this.onAllReported = cb;
1094
1219
  }
1095
- /** Set the CC team name (extracted from spawn confirmation tool_result). */
1096
1220
  setTeamName(name) {
1097
1221
  this.teamName = name;
1098
1222
  this.mailboxPath = join(homedir(), '.claude', 'teams', name, 'inboxes', 'team-lead.json');
1099
1223
  }
1100
1224
  get currentTeamName() { return this.teamName; }
1101
1225
  get isMailboxWatching() { return this.mailboxWatching; }
1102
- /** Start watching the mailbox file for sub-agent results. */
1103
1226
  startMailboxWatch() {
1104
- if (this.mailboxWatching) {
1227
+ if (this.mailboxWatching)
1105
1228
  return;
1106
- }
1107
- if (!this.mailboxPath) {
1229
+ if (!this.mailboxPath)
1108
1230
  return;
1109
- }
1110
1231
  this.mailboxWatching = true;
1111
- // Ensure directory exists so watchFile doesn't error
1112
1232
  const dir = dirname(this.mailboxPath);
1113
1233
  if (!existsSync(dir)) {
1114
1234
  mkdirSync(dir, { recursive: true });
1115
1235
  }
1116
- // Start from 0 β€” process all messages including pre-existing ones
1117
- // Background agents may finish before the watcher starts
1118
1236
  this.lastMailboxCount = 0;
1119
- // Process immediately in case messages arrived before watching
1120
1237
  this.processMailbox();
1121
1238
  watchFile(this.mailboxPath, { interval: 2000 }, () => {
1122
1239
  this.processMailbox();
1123
1240
  });
1124
1241
  }
1125
- /** Stop watching the mailbox file. */
1126
1242
  stopMailboxWatch() {
1127
1243
  if (!this.mailboxWatching || !this.mailboxPath)
1128
1244
  return;
@@ -1132,7 +1248,6 @@ export class SubAgentTracker {
1132
1248
  catch { /* ignore */ }
1133
1249
  this.mailboxWatching = false;
1134
1250
  }
1135
- /** Read and parse the mailbox file. Returns [] on any error. */
1136
1251
  readMailboxMessages() {
1137
1252
  if (!this.mailboxPath || !existsSync(this.mailboxPath))
1138
1253
  return [];
@@ -1145,7 +1260,6 @@ export class SubAgentTracker {
1145
1260
  return [];
1146
1261
  }
1147
1262
  }
1148
- /** Process new mailbox messages and update sub-agent TG messages. */
1149
1263
  processMailbox() {
1150
1264
  const messages = this.readMailboxMessages();
1151
1265
  if (messages.length <= this.lastMailboxCount)
@@ -1153,64 +1267,60 @@ export class SubAgentTracker {
1153
1267
  const newMessages = messages.slice(this.lastMailboxCount);
1154
1268
  this.lastMailboxCount = messages.length;
1155
1269
  for (const msg of newMessages) {
1156
- // Don't filter by msg.read β€” CC may read its mailbox before our 2s poll fires
1157
- // We track by message count (lastMailboxCount) to avoid duplicates
1158
- // Skip idle notifications (JSON objects, not real results)
1159
1270
  if (msg.text.startsWith('{'))
1160
1271
  continue;
1161
- // Match msg.from to a tracked sub-agent
1162
1272
  const matched = this.findAgentByFrom(msg.from);
1163
1273
  if (!matched) {
1164
1274
  console.error(`[MAILBOX] No match for from="${msg.from}". Agents: ${[...this.agents.values()].map(a => `${a.agentName}/${a.label}/${a.status}`).join(', ')}`);
1165
1275
  continue;
1166
1276
  }
1167
1277
  matched.status = 'completed';
1168
- if (!matched.tgMessageId)
1169
- continue;
1170
- // React with βœ… instead of editing β€” avoids race conditions
1171
- const msgId = matched.tgMessageId;
1172
- const emoji = msg.color === 'red' ? 'πŸ‘Ž' : 'πŸ‘';
1173
- this.sendQueue = this.sendQueue.then(async () => {
1174
- try {
1175
- await this.sender.setReaction?.(this.chatId, msgId, emoji);
1176
- }
1177
- catch {
1178
- // Reaction failure β€” non-critical, might not be supported
1179
- }
1180
- }).catch(() => { });
1278
+ // Update standalone bubble or set reaction on its message
1279
+ if (!this.inTurn && this.standaloneMsgId) {
1280
+ this.updateStandaloneMessage();
1281
+ // Also react on the standalone bubble
1282
+ const msgId = this.standaloneMsgId;
1283
+ const emoji = msg.color === 'red' ? 'πŸ‘Ž' : 'πŸ‘';
1284
+ this.sendQueue = this.sendQueue.then(async () => {
1285
+ try {
1286
+ await this.sender.setReaction?.(this.chatId, msgId, emoji);
1287
+ }
1288
+ catch { /* non-critical */ }
1289
+ }).catch(() => { });
1290
+ }
1291
+ else if (matched.tgMessageId) {
1292
+ const msgId = matched.tgMessageId;
1293
+ const emoji = msg.color === 'red' ? 'πŸ‘Ž' : 'πŸ‘';
1294
+ this.sendQueue = this.sendQueue.then(async () => {
1295
+ try {
1296
+ await this.sender.setReaction?.(this.chatId, msgId, emoji);
1297
+ }
1298
+ catch { /* non-critical */ }
1299
+ }).catch(() => { });
1300
+ }
1181
1301
  }
1182
- // Check if ALL dispatched agents are now completed
1183
1302
  if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
1184
- // All done β€” invoke callback
1185
1303
  const cb = this.onAllReported;
1186
- // Defer slightly to let edits flush
1187
1304
  setTimeout(() => cb(), 500);
1188
1305
  }
1189
1306
  }
1190
- /** Find a tracked sub-agent whose label matches the mailbox message's `from` field. */
1191
1307
  findAgentByFrom(from) {
1192
1308
  const fromLower = from.toLowerCase();
1193
1309
  for (const info of this.agents.values()) {
1194
1310
  if (info.status !== 'dispatched')
1195
1311
  continue;
1196
- // Primary match: agentName (CC's internal agent name, used as mailbox 'from')
1197
- if (info.agentName && info.agentName.toLowerCase() === fromLower) {
1312
+ if (info.agentName && info.agentName.toLowerCase() === fromLower)
1198
1313
  return info;
1199
- }
1200
- // Fallback: fuzzy label match
1201
1314
  const label = (info.label || info.toolName).toLowerCase();
1202
- if (label === fromLower || label.includes(fromLower) || fromLower.includes(label)) {
1315
+ if (label === fromLower || label.includes(fromLower) || fromLower.includes(label))
1203
1316
  return info;
1204
- }
1205
1317
  }
1206
1318
  return null;
1207
1319
  }
1208
- /** Handle a system task_started event β€” update the sub-agent status display. */
1209
- handleTaskStarted(toolUseId, description, taskType) {
1320
+ handleTaskStarted(toolUseId, description, _taskType) {
1210
1321
  const info = this.agents.get(toolUseId);
1211
1322
  if (!info)
1212
1323
  return;
1213
- // Use task_started description as label if we don't have one yet or current is low-priority
1214
1324
  if (description && labelFieldPriority('description') < labelFieldPriority(info.labelField)) {
1215
1325
  info.label = description.slice(0, 80);
1216
1326
  info.labelField = 'description';
@@ -1218,84 +1328,86 @@ export class SubAgentTracker {
1218
1328
  info.status = 'dispatched';
1219
1329
  if (!info.dispatchedAt)
1220
1330
  info.dispatchedAt = Date.now();
1221
- this.updateConsolidatedAgentMessage();
1331
+ if (!this.inTurn)
1332
+ this.updateStandaloneMessage();
1222
1333
  }
1223
- /** Handle a system task_progress event β€” update the sub-agent status with current activity. */
1224
1334
  handleTaskProgress(toolUseId, description, lastToolName) {
1225
1335
  const info = this.agents.get(toolUseId);
1226
- if (!info)
1336
+ if (!info || info.status === 'completed')
1227
1337
  return;
1228
- if (info.status === 'completed')
1229
- return; // Don't update completed agents
1230
- // Update the consolidated message with progress info
1231
- this.updateConsolidatedAgentMessageWithProgress(toolUseId, description, lastToolName);
1338
+ const line = formatProgressLine(description, lastToolName);
1339
+ if (line) {
1340
+ info.progressLines.push(line);
1341
+ if (info.progressLines.length > MAX_PROGRESS_LINES)
1342
+ info.progressLines.shift();
1343
+ }
1344
+ if (!this.inTurn)
1345
+ this.updateStandaloneMessage();
1232
1346
  }
1233
- /** Handle a system task_completed event. */
1234
1347
  handleTaskCompleted(toolUseId) {
1235
1348
  const info = this.agents.get(toolUseId);
1236
1349
  if (!info || info.status === 'completed')
1237
1350
  return;
1238
1351
  info.status = 'completed';
1239
- this.updateConsolidatedAgentMessage();
1240
- // Check if all agents are done
1352
+ if (!this.inTurn)
1353
+ this.updateStandaloneMessage();
1241
1354
  const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
1242
1355
  if (allDone && this.onAllReported) {
1243
1356
  this.onAllReported();
1244
1357
  this.stopMailboxWatch();
1245
1358
  }
1246
1359
  }
1247
- /** Build and edit the shared sub-agent status message with progress info. */
1248
- updateConsolidatedAgentMessageWithProgress(progressToolUseId, progressDesc, lastTool) {
1249
- if (!this.consolidatedAgentMsgId)
1250
- return;
1251
- const msgId = this.consolidatedAgentMsgId;
1252
- const lines = [];
1360
+ /** Build the standalone status bubble HTML. */
1361
+ buildStandaloneHtml() {
1362
+ const entries = [];
1253
1363
  for (const info of this.agents.values()) {
1254
1364
  const label = info.label || info.agentName || info.toolName;
1255
- if (info.toolUseId === progressToolUseId && info.status !== 'completed') {
1256
- const toolInfo = lastTool ? ` (${lastTool})` : '';
1257
- const desc = progressDesc ? `: ${progressDesc.slice(0, 60)}` : '';
1258
- lines.push(`πŸ€– ${escapeHtml(label)} β€” Working${toolInfo}${desc}`);
1365
+ const elapsed = formatElapsed(Date.now() - info.startTime);
1366
+ let statusLine;
1367
+ if (info.status === 'completed') {
1368
+ statusLine = `πŸ€– ${escapeHtml(label)} β€” βœ… Done (${elapsed})`;
1259
1369
  }
1260
1370
  else {
1261
- const status = info.status === 'completed' ? 'βœ… Done'
1262
- : info.status === 'dispatched' ? 'Waiting for results…'
1263
- : 'Working…';
1264
- lines.push(`πŸ€– ${escapeHtml(label)} β€” ${status}`);
1371
+ statusLine = `πŸ€– ${escapeHtml(label)} β€” Working (${elapsed})…`;
1265
1372
  }
1373
+ const progressBlock = info.progressLines.length > 0
1374
+ ? '\n' + info.progressLines.join('\n')
1375
+ : '';
1376
+ entries.push(statusLine + progressBlock);
1266
1377
  }
1267
- const text = lines.join('\n');
1378
+ return `<blockquote>${entries.join('\n\n')}</blockquote>`;
1379
+ }
1380
+ /** Edit the standalone status bubble with current state. */
1381
+ updateStandaloneMessage() {
1382
+ if (!this.standaloneMsgId)
1383
+ return;
1384
+ const msgId = this.standaloneMsgId;
1385
+ const html = this.buildStandaloneHtml();
1268
1386
  this.sendQueue = this.sendQueue.then(async () => {
1269
1387
  try {
1270
- await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1271
- }
1272
- catch {
1273
- // Progress message edit failure β€” non-critical
1388
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
1274
1389
  }
1390
+ catch { /* non-critical */ }
1275
1391
  }).catch(() => { });
1276
1392
  }
1277
- /** Find a tracked sub-agent by tool_use_id. */
1278
1393
  getAgentByToolUseId(toolUseId) {
1279
1394
  return this.agents.get(toolUseId);
1280
1395
  }
1281
1396
  reset() {
1282
- // Stop mailbox watching
1283
1397
  this.stopMailboxWatch();
1284
- // Clear all elapsed timers before resetting
1285
- for (const info of this.agents.values()) {
1286
- }
1287
1398
  this.agents.clear();
1288
1399
  this.blockToAgent.clear();
1289
- this.consolidatedAgentMsgId = null;
1400
+ this.standaloneMsgId = null;
1290
1401
  this.sendQueue = Promise.resolve();
1291
1402
  this.teamName = null;
1292
1403
  this.mailboxPath = null;
1293
1404
  this.lastMailboxCount = 0;
1294
1405
  this.onAllReported = null;
1406
+ this.inTurn = true;
1295
1407
  }
1296
1408
  }
1297
1409
  // ── Utility: split a completed text into TG-sized chunks ──
1298
- export function splitText(text, maxLength = 4000) {
1410
+ export function splitText(text, maxLength = 3500) {
1299
1411
  if (text.length <= maxLength)
1300
1412
  return [text];
1301
1413
  const chunks = [];