@fonz/tgcc 0.6.18 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +77 -0
  2. package/dist/bridge.js +41 -11
  3. package/dist/bridge.js.map +1 -1
  4. package/dist/plugin/index.d.ts +41 -0
  5. package/dist/plugin/index.js +161 -0
  6. package/dist/plugin/index.js.map +1 -0
  7. package/dist/plugin/openclaw.plugin.json +55 -0
  8. package/dist/plugin/package.json +6 -0
  9. package/dist/plugin/skills/tgcc-agents/SKILL.md +161 -0
  10. package/dist/plugin/src/client.d.ts +167 -0
  11. package/dist/plugin/src/client.js +523 -0
  12. package/dist/plugin/src/client.js.map +1 -0
  13. package/dist/plugin/src/events.d.ts +44 -0
  14. package/dist/plugin/src/events.js +226 -0
  15. package/dist/plugin/src/events.js.map +1 -0
  16. package/dist/plugin/src/permissions.d.ts +21 -0
  17. package/dist/plugin/src/permissions.js +78 -0
  18. package/dist/plugin/src/permissions.js.map +1 -0
  19. package/dist/plugin/src/tools/tgcc-kill.d.ts +6 -0
  20. package/dist/plugin/src/tools/tgcc-kill.js +52 -0
  21. package/dist/plugin/src/tools/tgcc-kill.js.map +1 -0
  22. package/dist/plugin/src/tools/tgcc-send.d.ts +9 -0
  23. package/dist/plugin/src/tools/tgcc-send.js +61 -0
  24. package/dist/plugin/src/tools/tgcc-send.js.map +1 -0
  25. package/dist/plugin/src/tools/tgcc-spawn.d.ts +9 -0
  26. package/dist/plugin/src/tools/tgcc-spawn.js +79 -0
  27. package/dist/plugin/src/tools/tgcc-spawn.js.map +1 -0
  28. package/dist/plugin/src/tools/tgcc-status.d.ts +9 -0
  29. package/dist/plugin/src/tools/tgcc-status.js +74 -0
  30. package/dist/plugin/src/tools/tgcc-status.js.map +1 -0
  31. package/dist/streaming.d.ts +61 -89
  32. package/dist/streaming.js +672 -677
  33. package/dist/streaming.js.map +1 -1
  34. package/dist/telegram-html-ast.js +3 -0
  35. package/dist/telegram-html-ast.js.map +1 -1
  36. package/package.json +10 -3
  37. package/plugin/openclaw.plugin.json +55 -0
  38. package/plugin/skills/tgcc-agents/SKILL.md +161 -0
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,339 @@ 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
+ // No segment crossed the threshold — shouldn't happen, but truncate as last resort
525
+ const html = this.renderHtml().slice(0, this.splitThreshold);
526
+ await this._doSendOrEdit(html);
527
+ return;
528
+ }
529
+ // Ensure we always make forward progress: send at least one segment
530
+ if (splitSegIdx === 0)
531
+ splitSegIdx = 1;
532
+ // Render first part, start new message with remainder
533
+ const firstSegs = this.segments.slice(0, splitSegIdx);
534
+ const restSegs = this.segments.slice(splitSegIdx);
535
+ const firstHtml = firstSegs.map(s => s.content).filter(Boolean).join('\n');
536
+ await this._doSendOrEdit(firstHtml);
537
+ // Start a new message for remainder
538
+ this.tgMessageId = null;
539
+ this.segments = restSegs;
540
+ const restHtml = this.renderHtml();
541
+ await this._doSendOrEdit(restHtml);
542
+ }
543
+ checkFirstSendReady() {
544
+ if (this.firstSendReady)
545
+ return true;
546
+ const textChars = this.segments
547
+ .filter((s) => s.type === 'text')
548
+ .reduce((sum, s) => sum + s.rawText.length, 0);
549
+ if (textChars >= 200 || Date.now() - this.turnStartTime >= 2000) {
550
+ this.firstSendReady = true;
551
+ this.clearFirstSendTimer();
552
+ return true;
553
+ }
554
+ return false;
555
+ }
556
+ clearFirstSendTimer() {
557
+ if (this.firstSendTimer) {
558
+ clearTimeout(this.firstSendTimer);
559
+ this.firstSendTimer = null;
560
+ }
561
+ }
562
+ /** Force-split when a text segment exceeds 50KB */
563
+ async forceSplitText(seg) {
564
+ const maxChars = 40_000;
565
+ const splitAt = findSplitPoint(seg.rawText, maxChars);
566
+ const firstPart = seg.rawText.slice(0, splitAt);
567
+ const remainder = seg.rawText.slice(splitAt);
568
+ // Replace the current text segment with truncated first part
569
+ seg.rawText = firstPart;
570
+ seg.content = renderSegment(seg);
571
+ await this.sendOrEdit(this.renderHtml());
572
+ // Start new message for remainder
573
+ this.tgMessageId = null;
574
+ const newSeg = { type: 'text', rawText: remainder, content: '' };
575
+ newSeg.content = renderSegment(newSeg);
576
+ this.segments = [newSeg];
577
+ this.currentSegmentIdx = 0;
578
+ await this.sendOrEdit(this.renderHtml());
579
+ }
580
+ async finalize() {
581
+ if (this.sealed)
582
+ return; // already finalized — guard against double-call (result + exit)
583
+ // Cancel any pending flush — we take over from here
584
+ this.clearFlushTimer();
585
+ // Ensure first send is unblocked — finalize is the last chance to send anything
586
+ this.firstSendReady = true;
587
+ this.clearFirstSendTimer();
588
+ // Append usage footer segment
589
+ if (this.turnUsage) {
590
+ const usageHtml = formatUsageFooter(this.turnUsage, this.turnUsage.model);
591
+ const seg = { type: 'usage', content: `<blockquote>📊 ${usageHtml}</blockquote>` };
592
+ this.segments.push(seg);
593
+ }
594
+ // Final render — chain directly onto sendQueue so it runs after any in-flight edits
595
+ const html = this.renderHtml();
596
+ if (html && html !== '…') {
597
+ this.sendQueue = this.sendQueue
598
+ .then(() => this._doSendOrEdit(html))
599
+ .catch(err => {
600
+ this.logger?.error?.({ err }, 'finalize failed');
601
+ this.onError?.(err, 'Failed to send/edit message');
602
+ });
603
+ await this.sendQueue;
604
+ }
605
+ this.sealed = true;
606
+ }
213
607
  // ── 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 => {
608
+ async sendOrEdit(html) {
609
+ const safeHtml = html || '…';
610
+ this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(safeHtml)).catch(err => {
217
611
  this.logger?.error?.({ err }, 'sendOrEdit failed');
218
612
  this.onError?.(err, 'Failed to send/edit message');
219
613
  });
220
614
  return this.sendQueue;
221
615
  }
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
616
+ async _doSendOrEdit(html) {
617
+ let text = html || '';
618
+ if (!text.replace(/<[^>]*>/g, '').trim())
619
+ text = '';
228
620
  this.lastEditTime = Date.now();
229
621
  try {
230
622
  if (!this.tgMessageId) {
231
- this.tgMessageId = await this.sender.sendMessage(this.chatId, safeText, 'HTML');
623
+ this.tgMessageId = await this.sender.sendMessage(this.chatId, text, 'HTML');
232
624
  this.messageIds.push(this.tgMessageId);
233
625
  }
234
626
  else {
235
- await this.sender.editMessage(this.chatId, this.tgMessageId, safeText, 'HTML');
627
+ await this.sender.editMessage(this.chatId, this.tgMessageId, text, 'HTML');
236
628
  }
237
629
  }
238
630
  catch (err) {
239
631
  const errorCode = err && typeof err === 'object' && 'error_code' in err
240
632
  ? err.error_code : 0;
241
- // Handle TG rate limit (429) — retry
242
633
  if (errorCode === 429) {
243
634
  const retryAfter = err.parameters?.retry_after ?? 5;
244
635
  this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
245
636
  await sleep(retryAfter * 1000);
246
637
  return this._doSendOrEdit(text);
247
638
  }
248
- // Ignore "message is not modified" errors (harmless)
249
639
  if (err instanceof Error && err.message.includes('message is not modified'))
250
640
  return;
251
- // 400 (bad request), 403 (forbidden), and all other errors — log and skip, never throw
252
641
  this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit — skipping');
253
642
  }
254
643
  }
@@ -261,377 +650,54 @@ export class StreamAccumulator {
261
650
  this.messageIds.push(msgId);
262
651
  }
263
652
  catch (err) {
264
- // Fall back to text indicator on failure
265
653
  this.logger?.error?.({ err }, 'Failed to send image');
266
- this.buffer += '\n[Image could not be sent]';
267
654
  }
268
655
  this.imageBase64Buffer = '';
269
656
  }
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. */
657
+ /** Soft reset: clear per-API-call transient state. Segments and tgMessageId persist across
658
+ * tool-use loop iterations within the same turn. */
599
659
  softReset() {
600
- this.buffer = '';
601
- this.thinkingBuffer = '';
602
- this.imageBase64Buffer = '';
603
660
  this.currentBlockType = null;
604
- this.currentToolBlockId = null;
605
- this.lastEditTime = 0;
606
- this.thinkingIndicatorShown = false;
607
- this.thinkingMessageId = null;
608
- this.finished = false;
661
+ this.currentBlockId = null;
662
+ this.currentSegmentIdx = -1;
663
+ this.sealed = false;
609
664
  this.turnUsage = null;
610
665
  this._lastMsgStartCtx = null;
611
- this.clearEditTimer();
666
+ this.clearFlushTimer();
667
+ for (const t of this.toolHideTimers.values())
668
+ clearTimeout(t);
669
+ this.toolHideTimers.clear();
612
670
  }
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. */
671
+ /** Full reset: clear everything for a new turn. */
616
672
  reset() {
617
673
  const prevQueue = this.sendQueue;
618
674
  this.softReset();
675
+ this.segments = [];
619
676
  this.tgMessageId = null;
620
677
  this.messageIds = [];
621
- // Reset tool consolidation for next turn
622
- this.consolidatedToolMsgId = null;
623
- this.toolMessages.clear();
624
678
  this.toolInputBuffers.clear();
625
- this.toolIndicatorLastSummary.clear();
626
- this.toolResolvedStats.clear();
627
- this.sendQueue = prevQueue.catch(() => { }); // swallow errors from prev turn
679
+ this.imageBase64Buffer = '';
680
+ this.lastEditTime = 0;
681
+ this.dirty = false;
682
+ this.flushInFlight = false;
683
+ this.sendQueue = prevQueue.catch(() => { });
684
+ this.turnStartTime = Date.now();
685
+ this.firstSendReady = false;
686
+ this.clearFirstSendTimer();
687
+ for (const t of this.toolHideTimers.values())
688
+ clearTimeout(t);
689
+ this.toolHideTimers.clear();
690
+ }
691
+ clearFlushTimer() {
692
+ if (this.flushTimer) {
693
+ clearTimeout(this.flushTimer);
694
+ this.flushTimer = null;
695
+ }
628
696
  }
629
697
  }
630
698
  // ── Helpers ──
631
- /** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
632
699
  /** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts → KYO/src/foo.ts */
633
700
  function shortenPath(p) {
634
- // Strip common prefixes
635
701
  return p
636
702
  .replace(/^\/home\/[^/]+\/Botverse\//, '')
637
703
  .replace(/^\/home\/[^/]+\/Projects\//, '')
@@ -640,7 +706,6 @@ function shortenPath(p) {
640
706
  function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
641
707
  if (!inputJson)
642
708
  return null;
643
- // Determine which field(s) to look for based on tool name
644
709
  const fieldsByTool = {
645
710
  Bash: ['command'],
646
711
  Read: ['file_path', 'path'],
@@ -650,22 +715,19 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
650
715
  Search: ['pattern', 'query'],
651
716
  Grep: ['pattern', 'query'],
652
717
  Glob: ['pattern'],
653
- TodoWrite: [], // handled specially below
654
- TaskOutput: [], // handled specially below
718
+ TodoWrite: [],
719
+ TaskOutput: [],
655
720
  };
656
721
  const skipTools = new Set(['TodoRead']);
657
722
  if (skipTools.has(toolName))
658
723
  return null;
659
724
  const fields = fieldsByTool[toolName];
660
725
  const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
661
- // Try parsing complete JSON first
662
726
  try {
663
727
  const parsed = JSON.parse(inputJson);
664
- // TaskOutput: show task ID compactly
665
728
  if (toolName === 'TaskOutput' && parsed.task_id) {
666
729
  return `collecting result · ${String(parsed.task_id).slice(0, 7)}`;
667
730
  }
668
- // TodoWrite: show in-progress item or summary
669
731
  if (toolName === 'TodoWrite' && Array.isArray(parsed.todos)) {
670
732
  const todos = parsed.todos;
671
733
  const inProgress = todos.find(t => t.status === 'in_progress');
@@ -677,6 +739,14 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
677
739
  const combined = prefix + label;
678
740
  return combined.length > maxLen ? combined.slice(0, maxLen) + '…' : combined;
679
741
  }
742
+ if (toolName === 'Grep' || toolName === 'Search') {
743
+ const pattern = (parsed.pattern || parsed.query || '').trim();
744
+ const searchPath = (parsed.path || parsed.glob || '').trim();
745
+ if (pattern) {
746
+ const combined = searchPath ? `${pattern} in ${shortenPath(searchPath)}` : pattern;
747
+ return combined.length > maxLen ? combined.slice(0, maxLen) + '…' : combined;
748
+ }
749
+ }
680
750
  if (fields) {
681
751
  for (const f of fields) {
682
752
  if (typeof parsed[f] === 'string' && parsed[f].trim()) {
@@ -687,7 +757,6 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
687
757
  }
688
758
  }
689
759
  }
690
- // Default: first string value
691
760
  for (const val of Object.values(parsed)) {
692
761
  if (typeof val === 'string' && val.trim()) {
693
762
  const v = val.trim();
@@ -698,11 +767,10 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
698
767
  }
699
768
  catch {
700
769
  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)
770
+ return null;
703
771
  const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
704
772
  for (const key of targetFields) {
705
- const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
773
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i');
706
774
  const m = inputJson.match(re);
707
775
  if (m?.[1]) {
708
776
  let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
@@ -714,9 +782,7 @@ function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireCompl
714
782
  return null;
715
783
  }
716
784
  }
717
- /** Extract a compact stat from a tool result for display in the indicator. */
718
785
  function extractToolResultStat(toolName, content, toolUseResult) {
719
- // For Edit/Write: use structured patch data if available
720
786
  if (toolUseResult && (toolName === 'Edit' || toolName === 'MultiEdit')) {
721
787
  const patches = toolUseResult.structuredPatch;
722
788
  if (patches?.length) {
@@ -743,17 +809,13 @@ function extractToolResultStat(toolName, content, toolUseResult) {
743
809
  }
744
810
  if (toolUseResult && toolName === 'Write') {
745
811
  const c = toolUseResult.content;
746
- if (c) {
747
- const lines = c.split('\n').length;
748
- return `${lines} lines`;
749
- }
812
+ if (c)
813
+ return `${c.split('\n').length} lines`;
750
814
  }
751
815
  if (!content)
752
816
  return '';
753
817
  const first = content.split('\n')[0].trim();
754
- // Skip generic "The file X has been updated/created" messages
755
818
  if (/^(The file |File created|Successfully)/.test(first)) {
756
- // Try to extract something useful anyway
757
819
  const lines = content.match(/(\d+)\s*lines?/i);
758
820
  if (lines)
759
821
  return `${lines[1]} lines`;
@@ -806,25 +868,49 @@ function extractToolResultStat(toolName, content, toolUseResult) {
806
868
  }
807
869
  }
808
870
  function findSplitPoint(text, threshold) {
809
- // Try to split at paragraph break
810
871
  const paragraphBreak = text.lastIndexOf('\n\n', threshold);
811
872
  if (paragraphBreak > threshold * 0.5)
812
873
  return paragraphBreak;
813
- // Try to split at line break
814
874
  const lineBreak = text.lastIndexOf('\n', threshold);
815
875
  if (lineBreak > threshold * 0.5)
816
876
  return lineBreak;
817
- // Try to split at sentence end
818
877
  const sentenceEnd = text.lastIndexOf('. ', threshold);
819
878
  if (sentenceEnd > threshold * 0.5)
820
879
  return sentenceEnd + 2;
821
- // Fall back to threshold
822
880
  return threshold;
823
881
  }
824
882
  function sleep(ms) {
825
883
  return new Promise(resolve => setTimeout(resolve, ms));
826
884
  }
827
- /** Format token count as human-readable: 1234 → "1.2k", 500 → "500" */
885
+ function formatElapsed(ms) {
886
+ const s = Math.floor(ms / 1000);
887
+ const m = Math.floor(s / 60);
888
+ if (m > 0)
889
+ return `${m}m ${s % 60}s`;
890
+ return `${s}s`;
891
+ }
892
+ const MAX_PROGRESS_LINES = 5;
893
+ /** Format a task_progress event into a single HTML-safe progress line.
894
+ * Returns null if the event has no useful display content. */
895
+ export function formatProgressLine(description, lastToolName) {
896
+ const desc = description?.trim();
897
+ if (!desc)
898
+ return null;
899
+ const lower = desc.toLowerCase();
900
+ const tool = lastToolName?.toLowerCase() ?? '';
901
+ let emoji = '📋';
902
+ if (tool === 'bash' && /build|compile|npm run|pnpm|yarn|make/.test(lower)) {
903
+ emoji = '🔨';
904
+ }
905
+ else if (tool === 'bash' && /git commit|git push/.test(lower)) {
906
+ emoji = '📝';
907
+ }
908
+ else if (/context.*%|%.*context|\d{2,3}%/.test(lower)) {
909
+ emoji = '🧠';
910
+ }
911
+ const truncated = desc.length > 60 ? desc.slice(0, 60) + '…' : desc;
912
+ return `${emoji} ${escapeHtml(truncated)}`;
913
+ }
828
914
  function formatTokens(n) {
829
915
  if (n >= 1000)
830
916
  return (n / 1000).toFixed(1) + 'k';
@@ -832,8 +918,6 @@ function formatTokens(n) {
832
918
  }
833
919
  /** Format usage stats as an HTML italic footer line */
834
920
  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
921
  const ctxInput = usage.ctxInputTokens ?? usage.inputTokens;
838
922
  const ctxRead = usage.ctxCacheReadTokens ?? usage.cacheReadTokens;
839
923
  const ctxCreation = usage.ctxCacheCreationTokens ?? usage.cacheCreationTokens;
@@ -853,17 +937,14 @@ export function formatUsageFooter(usage, _model) {
853
937
  }
854
938
  // ── Sub-agent detection patterns ──
855
939
  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
940
+ 'Task',
941
+ 'dispatch_agent',
942
+ 'create_agent',
943
+ 'AgentRunner'
860
944
  ]);
861
945
  export function isSubAgentTool(toolName) {
862
946
  return CC_SUB_AGENT_TOOLS.has(toolName);
863
947
  }
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
948
  export function extractSubAgentSummary(jsonInput, maxLen = 150) {
868
949
  try {
869
950
  const parsed = JSON.parse(jsonInput);
@@ -873,7 +954,6 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
873
954
  }
874
955
  }
875
956
  catch {
876
- // Partial JSON — try regex extraction for common patterns
877
957
  for (const key of ['prompt', 'task', 'command', 'description', 'message']) {
878
958
  const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'i');
879
959
  const m = jsonInput.match(re);
@@ -885,22 +965,15 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
885
965
  }
886
966
  return '';
887
967
  }
888
- /** Label fields in priority order (index 0 = highest priority). */
889
968
  const LABEL_FIELDS = ['name', 'description', 'subagent_type', 'team_name'];
890
- /** Priority index for a label source field. Lower = better. */
891
969
  export function labelFieldPriority(field) {
892
970
  if (!field)
893
- return LABEL_FIELDS.length + 1; // worst
971
+ return LABEL_FIELDS.length + 1;
894
972
  const idx = LABEL_FIELDS.indexOf(field);
895
973
  return idx >= 0 ? idx : LABEL_FIELDS.length;
896
974
  }
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
975
  export function extractAgentLabel(jsonInput) {
903
- const summaryField = 'prompt'; // last resort — first line of prompt
976
+ const summaryField = 'prompt';
904
977
  try {
905
978
  const parsed = JSON.parse(jsonInput);
906
979
  for (const key of LABEL_FIELDS) {
@@ -917,13 +990,10 @@ export function extractAgentLabel(jsonInput) {
917
990
  return { label: '', field: null };
918
991
  }
919
992
  catch {
920
- // JSON incomplete during streaming — extract first complete field value
921
993
  const result = extractFieldFromPartialJsonWithField(jsonInput, LABEL_FIELDS);
922
994
  return result ?? { label: '', field: null };
923
995
  }
924
996
  }
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
997
  function extractFieldFromPartialJsonWithField(input, keys) {
928
998
  for (const key of keys) {
929
999
  const idx = input.indexOf(`"${key}"`);
@@ -936,7 +1006,6 @@ function extractFieldFromPartialJsonWithField(input, keys) {
936
1006
  const afterColon = afterKey.slice(colonIdx + 1).trimStart();
937
1007
  if (!afterColon.startsWith('"'))
938
1008
  continue;
939
- // Walk the string handling escapes
940
1009
  let i = 1, value = '';
941
1010
  while (i < afterColon.length) {
942
1011
  if (afterColon[i] === '\\' && i + 1 < afterColon.length) {
@@ -959,9 +1028,9 @@ function extractFieldFromPartialJsonWithField(input, keys) {
959
1028
  export class SubAgentTracker {
960
1029
  chatId;
961
1030
  sender;
962
- agents = new Map(); // toolUseId → info
963
- blockToAgent = new Map(); // blockIndex → toolUseId
964
- consolidatedAgentMsgId = null; // shared TG message for all sub-agents
1031
+ agents = new Map();
1032
+ blockToAgent = new Map();
1033
+ standaloneMsgId = null; // post-turn standalone status bubble
965
1034
  sendQueue = Promise.resolve();
966
1035
  teamName = null;
967
1036
  mailboxPath = null;
@@ -969,6 +1038,9 @@ export class SubAgentTracker {
969
1038
  lastMailboxCount = 0;
970
1039
  onAllReported = null;
971
1040
  hasPendingFollowUp = false;
1041
+ /** When true, stream events update agent metadata but do NOT create TG messages.
1042
+ * Set to false after the main turn bubble is sealed to allow standalone status bubble. */
1043
+ inTurn = true;
972
1044
  constructor(options) {
973
1045
  this.chatId = options.chatId;
974
1046
  this.sender = options.sender;
@@ -976,34 +1048,41 @@ export class SubAgentTracker {
976
1048
  get activeAgents() {
977
1049
  return [...this.agents.values()];
978
1050
  }
979
- /** Returns true if any sub-agents were tracked in this turn (including completed ones) */
980
1051
  get hadSubAgents() {
981
1052
  return this.agents.size > 0;
982
1053
  }
983
- /** Returns true if any sub-agents are in dispatched state (spawned but no result yet) */
984
1054
  get hasDispatchedAgents() {
985
1055
  return [...this.agents.values()].some(a => a.status === 'dispatched');
986
1056
  }
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
- */
1057
+ /** Called after the main bubble is sealed. Creates standalone status bubble for any dispatched agents. */
1058
+ async startPostTurnTracking() {
1059
+ this.inTurn = false;
1060
+ if (!this.hasDispatchedAgents)
1061
+ return;
1062
+ // Create the standalone status bubble
1063
+ const html = this.buildStandaloneHtml();
1064
+ this.sendQueue = this.sendQueue.then(async () => {
1065
+ try {
1066
+ const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
1067
+ this.standaloneMsgId = msgId;
1068
+ // Set tgMessageId on all dispatched agents pointing to this standalone bubble
1069
+ for (const info of this.agents.values()) {
1070
+ if (info.status === 'dispatched') {
1071
+ info.tgMessageId = msgId;
1072
+ }
1073
+ }
1074
+ }
1075
+ catch {
1076
+ // Non-critical
1077
+ }
1078
+ }).catch(() => { });
1079
+ await this.sendQueue;
1080
+ }
990
1081
  markDispatchedAsReportedInMain() {
991
1082
  for (const [, info] of this.agents) {
992
- if (info.status !== 'dispatched' || !info.tgMessageId)
1083
+ if (info.status !== 'dispatched')
993
1084
  continue;
994
1085
  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
1086
  }
1008
1087
  }
1009
1088
  async handleEvent(event) {
@@ -1021,12 +1100,8 @@ export class SubAgentTracker {
1021
1100
  case 'content_block_stop':
1022
1101
  await this.onBlockStop(event);
1023
1102
  break;
1024
- // NOTE: message_start reset is handled by the bridge (not here)
1025
- // so it can check hadSubAgents before clearing state
1026
1103
  }
1027
1104
  }
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
1105
  setAgentMetadata(toolUseId, meta) {
1031
1106
  const info = this.agents.get(toolUseId);
1032
1107
  if (!info)
@@ -1034,13 +1109,13 @@ export class SubAgentTracker {
1034
1109
  if (meta.agentName)
1035
1110
  info.agentName = meta.agentName;
1036
1111
  }
1037
- /** Mark an agent as completed externally (e.g. from bridge follow-up) */
1038
1112
  markCompleted(toolUseId, _reason) {
1039
1113
  const info = this.agents.get(toolUseId);
1040
1114
  if (!info || info.status === 'completed')
1041
1115
  return;
1042
1116
  info.status = 'completed';
1043
- // Check if all agents are done
1117
+ if (!this.inTurn)
1118
+ this.updateStandaloneMessage();
1044
1119
  const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
1045
1120
  if (allDone && this.onAllReported) {
1046
1121
  this.onAllReported();
@@ -1049,47 +1124,27 @@ export class SubAgentTracker {
1049
1124
  }
1050
1125
  async handleToolResult(toolUseId, result) {
1051
1126
  const info = this.agents.get(toolUseId);
1052
- if (!info || !info.tgMessageId)
1127
+ if (!info)
1053
1128
  return;
1054
- // Detect background agent spawn confirmations — keep as dispatched, don't mark completed
1055
- // Spawn confirmations contain "agent_id:" and "Spawned" patterns
1056
1129
  const isSpawnConfirmation = /agent_id:\s*\S+@\S+/.test(result) || /[Ss]pawned\s+successfully/i.test(result);
1057
1130
  if (isSpawnConfirmation) {
1058
- // Extract agent name from spawn confirmation for mailbox matching
1059
1131
  const nameMatch = result.match(/name:\s*(\S+)/);
1060
1132
  if (nameMatch && !info.agentName)
1061
1133
  info.agentName = nameMatch[1];
1062
1134
  const agentIdMatch = result.match(/agent_id:\s*(\S+)@/);
1063
1135
  if (agentIdMatch && !info.agentName)
1064
1136
  info.agentName = agentIdMatch[1];
1065
- // Mark as dispatched — this enables mailbox watching and prevents idle timeout
1066
1137
  info.status = 'dispatched';
1067
1138
  info.dispatchedAt = Date.now();
1068
- this.updateConsolidatedAgentMessage();
1139
+ if (!this.inTurn)
1140
+ this.updateStandaloneMessage();
1069
1141
  return;
1070
1142
  }
1071
- // Skip if already completed (e.g. via mailbox)
1072
1143
  if (info.status === 'completed')
1073
1144
  return;
1074
1145
  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;
1146
+ if (!this.inTurn)
1147
+ this.updateStandaloneMessage();
1093
1148
  }
1094
1149
  async onBlockStart(event) {
1095
1150
  if (event.content_block.type !== 'tool_use')
@@ -1107,64 +1162,26 @@ export class SubAgentTracker {
1107
1162
  labelField: null,
1108
1163
  agentName: '',
1109
1164
  inputPreview: '',
1165
+ startTime: Date.now(),
1110
1166
  dispatchedAt: null,
1167
+ progressLines: [],
1111
1168
  };
1112
1169
  this.agents.set(block.id, info);
1113
1170
  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(() => { });
1171
+ // During the turn, StreamAccumulator renders the sub-agent segment.
1172
+ // Tracker only manages metadata here (no TG message).
1156
1173
  }
1157
1174
  async onInputDelta(event) {
1158
1175
  const toolUseId = this.blockToAgent.get(event.index);
1159
1176
  if (!toolUseId)
1160
1177
  return;
1161
1178
  const info = this.agents.get(toolUseId);
1162
- if (!info || !info.tgMessageId)
1179
+ if (!info)
1163
1180
  return;
1164
1181
  if (info.inputPreview.length < 10_000) {
1165
1182
  info.inputPreview += event.delta.partial_json;
1166
1183
  }
1167
- // Extract agent name from input JSON (used for mailbox matching)
1184
+ // Extract agent name for mailbox matching
1168
1185
  if (!info.agentName) {
1169
1186
  try {
1170
1187
  const parsed = JSON.parse(info.inputPreview);
@@ -1173,19 +1190,16 @@ export class SubAgentTracker {
1173
1190
  }
1174
1191
  }
1175
1192
  catch {
1176
- // Partial JSON — try extracting name field
1177
1193
  const nameMatch = info.inputPreview.match(/"name"\s*:\s*"([^"]+)"/);
1178
1194
  if (nameMatch)
1179
1195
  info.agentName = nameMatch[1];
1180
1196
  }
1181
1197
  }
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")
1198
+ // Extract label for standalone bubble (used post-turn)
1184
1199
  const extracted = extractAgentLabel(info.inputPreview);
1185
1200
  if (extracted.label && labelFieldPriority(extracted.field) < labelFieldPriority(info.labelField)) {
1186
1201
  info.label = extracted.label;
1187
1202
  info.labelField = extracted.field;
1188
- this.updateConsolidatedAgentMessage();
1189
1203
  }
1190
1204
  }
1191
1205
  async onBlockStop(event) {
@@ -1193,56 +1207,41 @@ export class SubAgentTracker {
1193
1207
  if (!toolUseId)
1194
1208
  return;
1195
1209
  const info = this.agents.get(toolUseId);
1196
- if (!info || !info.tgMessageId)
1210
+ if (!info)
1197
1211
  return;
1198
- // content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
1199
1212
  info.status = 'dispatched';
1200
1213
  info.dispatchedAt = Date.now();
1201
- // Final chance to extract label from complete input (may upgrade to higher-priority field)
1202
1214
  const finalExtracted = extractAgentLabel(info.inputPreview);
1203
1215
  if (finalExtracted.label && labelFieldPriority(finalExtracted.field) < labelFieldPriority(info.labelField)) {
1204
1216
  info.label = finalExtracted.label;
1205
1217
  info.labelField = finalExtracted.field;
1206
1218
  }
1207
- this.updateConsolidatedAgentMessage();
1208
- // Start elapsed timer — update every 15s to show progress
1209
1219
  }
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
1220
  setOnAllReported(cb) {
1213
1221
  this.onAllReported = cb;
1214
1222
  }
1215
- /** Set the CC team name (extracted from spawn confirmation tool_result). */
1216
1223
  setTeamName(name) {
1217
1224
  this.teamName = name;
1218
1225
  this.mailboxPath = join(homedir(), '.claude', 'teams', name, 'inboxes', 'team-lead.json');
1219
1226
  }
1220
1227
  get currentTeamName() { return this.teamName; }
1221
1228
  get isMailboxWatching() { return this.mailboxWatching; }
1222
- /** Start watching the mailbox file for sub-agent results. */
1223
1229
  startMailboxWatch() {
1224
- if (this.mailboxWatching) {
1230
+ if (this.mailboxWatching)
1225
1231
  return;
1226
- }
1227
- if (!this.mailboxPath) {
1232
+ if (!this.mailboxPath)
1228
1233
  return;
1229
- }
1230
1234
  this.mailboxWatching = true;
1231
- // Ensure directory exists so watchFile doesn't error
1232
1235
  const dir = dirname(this.mailboxPath);
1233
1236
  if (!existsSync(dir)) {
1234
1237
  mkdirSync(dir, { recursive: true });
1235
1238
  }
1236
- // Start from 0 — process all messages including pre-existing ones
1237
- // Background agents may finish before the watcher starts
1238
1239
  this.lastMailboxCount = 0;
1239
- // Process immediately in case messages arrived before watching
1240
1240
  this.processMailbox();
1241
1241
  watchFile(this.mailboxPath, { interval: 2000 }, () => {
1242
1242
  this.processMailbox();
1243
1243
  });
1244
1244
  }
1245
- /** Stop watching the mailbox file. */
1246
1245
  stopMailboxWatch() {
1247
1246
  if (!this.mailboxWatching || !this.mailboxPath)
1248
1247
  return;
@@ -1252,7 +1251,6 @@ export class SubAgentTracker {
1252
1251
  catch { /* ignore */ }
1253
1252
  this.mailboxWatching = false;
1254
1253
  }
1255
- /** Read and parse the mailbox file. Returns [] on any error. */
1256
1254
  readMailboxMessages() {
1257
1255
  if (!this.mailboxPath || !existsSync(this.mailboxPath))
1258
1256
  return [];
@@ -1265,7 +1263,6 @@ export class SubAgentTracker {
1265
1263
  return [];
1266
1264
  }
1267
1265
  }
1268
- /** Process new mailbox messages and update sub-agent TG messages. */
1269
1266
  processMailbox() {
1270
1267
  const messages = this.readMailboxMessages();
1271
1268
  if (messages.length <= this.lastMailboxCount)
@@ -1273,64 +1270,60 @@ export class SubAgentTracker {
1273
1270
  const newMessages = messages.slice(this.lastMailboxCount);
1274
1271
  this.lastMailboxCount = messages.length;
1275
1272
  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
1273
  if (msg.text.startsWith('{'))
1280
1274
  continue;
1281
- // Match msg.from to a tracked sub-agent
1282
1275
  const matched = this.findAgentByFrom(msg.from);
1283
1276
  if (!matched) {
1284
1277
  console.error(`[MAILBOX] No match for from="${msg.from}". Agents: ${[...this.agents.values()].map(a => `${a.agentName}/${a.label}/${a.status}`).join(', ')}`);
1285
1278
  continue;
1286
1279
  }
1287
1280
  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(() => { });
1281
+ // Update standalone bubble or set reaction on its message
1282
+ if (!this.inTurn && this.standaloneMsgId) {
1283
+ this.updateStandaloneMessage();
1284
+ // Also react on the standalone bubble
1285
+ const msgId = this.standaloneMsgId;
1286
+ const emoji = msg.color === 'red' ? '👎' : '👍';
1287
+ this.sendQueue = this.sendQueue.then(async () => {
1288
+ try {
1289
+ await this.sender.setReaction?.(this.chatId, msgId, emoji);
1290
+ }
1291
+ catch { /* non-critical */ }
1292
+ }).catch(() => { });
1293
+ }
1294
+ else if (matched.tgMessageId) {
1295
+ const msgId = matched.tgMessageId;
1296
+ const emoji = msg.color === 'red' ? '👎' : '👍';
1297
+ this.sendQueue = this.sendQueue.then(async () => {
1298
+ try {
1299
+ await this.sender.setReaction?.(this.chatId, msgId, emoji);
1300
+ }
1301
+ catch { /* non-critical */ }
1302
+ }).catch(() => { });
1303
+ }
1301
1304
  }
1302
- // Check if ALL dispatched agents are now completed
1303
1305
  if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
1304
- // All done — invoke callback
1305
1306
  const cb = this.onAllReported;
1306
- // Defer slightly to let edits flush
1307
1307
  setTimeout(() => cb(), 500);
1308
1308
  }
1309
1309
  }
1310
- /** Find a tracked sub-agent whose label matches the mailbox message's `from` field. */
1311
1310
  findAgentByFrom(from) {
1312
1311
  const fromLower = from.toLowerCase();
1313
1312
  for (const info of this.agents.values()) {
1314
1313
  if (info.status !== 'dispatched')
1315
1314
  continue;
1316
- // Primary match: agentName (CC's internal agent name, used as mailbox 'from')
1317
- if (info.agentName && info.agentName.toLowerCase() === fromLower) {
1315
+ if (info.agentName && info.agentName.toLowerCase() === fromLower)
1318
1316
  return info;
1319
- }
1320
- // Fallback: fuzzy label match
1321
1317
  const label = (info.label || info.toolName).toLowerCase();
1322
- if (label === fromLower || label.includes(fromLower) || fromLower.includes(label)) {
1318
+ if (label === fromLower || label.includes(fromLower) || fromLower.includes(label))
1323
1319
  return info;
1324
- }
1325
1320
  }
1326
1321
  return null;
1327
1322
  }
1328
- /** Handle a system task_started event — update the sub-agent status display. */
1329
- handleTaskStarted(toolUseId, description, taskType) {
1323
+ handleTaskStarted(toolUseId, description, _taskType) {
1330
1324
  const info = this.agents.get(toolUseId);
1331
1325
  if (!info)
1332
1326
  return;
1333
- // Use task_started description as label if we don't have one yet or current is low-priority
1334
1327
  if (description && labelFieldPriority('description') < labelFieldPriority(info.labelField)) {
1335
1328
  info.label = description.slice(0, 80);
1336
1329
  info.labelField = 'description';
@@ -1338,80 +1331,82 @@ export class SubAgentTracker {
1338
1331
  info.status = 'dispatched';
1339
1332
  if (!info.dispatchedAt)
1340
1333
  info.dispatchedAt = Date.now();
1341
- this.updateConsolidatedAgentMessage();
1334
+ if (!this.inTurn)
1335
+ this.updateStandaloneMessage();
1342
1336
  }
1343
- /** Handle a system task_progress event — update the sub-agent status with current activity. */
1344
1337
  handleTaskProgress(toolUseId, description, lastToolName) {
1345
1338
  const info = this.agents.get(toolUseId);
1346
- if (!info)
1339
+ if (!info || info.status === 'completed')
1347
1340
  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);
1341
+ const line = formatProgressLine(description, lastToolName);
1342
+ if (line) {
1343
+ info.progressLines.push(line);
1344
+ if (info.progressLines.length > MAX_PROGRESS_LINES)
1345
+ info.progressLines.shift();
1346
+ }
1347
+ if (!this.inTurn)
1348
+ this.updateStandaloneMessage();
1352
1349
  }
1353
- /** Handle a system task_completed event. */
1354
1350
  handleTaskCompleted(toolUseId) {
1355
1351
  const info = this.agents.get(toolUseId);
1356
1352
  if (!info || info.status === 'completed')
1357
1353
  return;
1358
1354
  info.status = 'completed';
1359
- this.updateConsolidatedAgentMessage();
1360
- // Check if all agents are done
1355
+ if (!this.inTurn)
1356
+ this.updateStandaloneMessage();
1361
1357
  const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
1362
1358
  if (allDone && this.onAllReported) {
1363
1359
  this.onAllReported();
1364
1360
  this.stopMailboxWatch();
1365
1361
  }
1366
1362
  }
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 = [];
1363
+ /** Build the standalone status bubble HTML. */
1364
+ buildStandaloneHtml() {
1365
+ const entries = [];
1373
1366
  for (const info of this.agents.values()) {
1374
1367
  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}`);
1368
+ const elapsed = formatElapsed(Date.now() - info.startTime);
1369
+ let statusLine;
1370
+ if (info.status === 'completed') {
1371
+ statusLine = `🤖 ${escapeHtml(label)} — ✅ Done (${elapsed})`;
1379
1372
  }
1380
1373
  else {
1381
- const status = info.status === 'completed' ? '✅ Done'
1382
- : info.status === 'dispatched' ? 'Waiting for results…'
1383
- : 'Working…';
1384
- lines.push(`🤖 ${escapeHtml(label)} — ${status}`);
1374
+ statusLine = `🤖 ${escapeHtml(label)} Working (${elapsed})…`;
1385
1375
  }
1376
+ const progressBlock = info.progressLines.length > 0
1377
+ ? '\n' + info.progressLines.join('\n')
1378
+ : '';
1379
+ entries.push(statusLine + progressBlock);
1386
1380
  }
1387
- const text = `<blockquote>${lines.join('\n')}</blockquote>`;
1381
+ return `<blockquote>${entries.join('\n\n')}</blockquote>`;
1382
+ }
1383
+ /** Edit the standalone status bubble with current state. */
1384
+ updateStandaloneMessage() {
1385
+ if (!this.standaloneMsgId)
1386
+ return;
1387
+ const msgId = this.standaloneMsgId;
1388
+ const html = this.buildStandaloneHtml();
1388
1389
  this.sendQueue = this.sendQueue.then(async () => {
1389
1390
  try {
1390
- await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
1391
- }
1392
- catch {
1393
- // Progress message edit failure — non-critical
1391
+ await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
1394
1392
  }
1393
+ catch { /* non-critical */ }
1395
1394
  }).catch(() => { });
1396
1395
  }
1397
- /** Find a tracked sub-agent by tool_use_id. */
1398
1396
  getAgentByToolUseId(toolUseId) {
1399
1397
  return this.agents.get(toolUseId);
1400
1398
  }
1401
1399
  reset() {
1402
- // Stop mailbox watching
1403
1400
  this.stopMailboxWatch();
1404
- // Clear all elapsed timers before resetting
1405
- for (const info of this.agents.values()) {
1406
- }
1407
1401
  this.agents.clear();
1408
1402
  this.blockToAgent.clear();
1409
- this.consolidatedAgentMsgId = null;
1403
+ this.standaloneMsgId = null;
1410
1404
  this.sendQueue = Promise.resolve();
1411
1405
  this.teamName = null;
1412
1406
  this.mailboxPath = null;
1413
1407
  this.lastMailboxCount = 0;
1414
1408
  this.onAllReported = null;
1409
+ this.inTurn = true;
1415
1410
  }
1416
1411
  }
1417
1412
  // ── Utility: split a completed text into TG-sized chunks ──