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