@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/bridge.js +41 -11
- package/dist/bridge.js.map +1 -1
- package/dist/streaming.d.ts +61 -89
- package/dist/streaming.js +669 -677
- package/dist/streaming.js.map +1 -1
- package/dist/telegram-html-ast.js +3 -0
- package/dist/telegram-html-ast.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
+
// message_stop within a tool-use loop — finalize is called separately by bridge on `result`
|
|
125
186
|
break;
|
|
126
187
|
}
|
|
127
188
|
}
|
|
128
|
-
|
|
189
|
+
onContentBlockStart(event) {
|
|
129
190
|
const blockType = event.content_block.type;
|
|
191
|
+
this.currentBlockType = blockType;
|
|
130
192
|
if (blockType === 'thinking') {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
167
|
-
|
|
168
|
-
if (
|
|
169
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
199
|
-
const blockId = this.
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(
|
|
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(
|
|
223
|
-
let
|
|
224
|
-
|
|
225
|
-
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
271
|
-
|
|
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.
|
|
605
|
-
this.
|
|
606
|
-
this.
|
|
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.
|
|
663
|
+
this.clearFlushTimer();
|
|
664
|
+
for (const t of this.toolHideTimers.values())
|
|
665
|
+
clearTimeout(t);
|
|
666
|
+
this.toolHideTimers.clear();
|
|
612
667
|
}
|
|
613
|
-
/** Full reset:
|
|
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.
|
|
626
|
-
this.
|
|
627
|
-
this.
|
|
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: [],
|
|
654
|
-
TaskOutput: [],
|
|
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;
|
|
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');
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
857
|
-
'dispatch_agent',
|
|
858
|
-
'create_agent',
|
|
859
|
-
'AgentRunner'
|
|
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;
|
|
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';
|
|
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();
|
|
963
|
-
blockToAgent = new Map();
|
|
964
|
-
|
|
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
|
-
/**
|
|
988
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1076
|
-
|
|
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
|
-
//
|
|
1115
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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.
|
|
1360
|
-
|
|
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
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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 ──
|