@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.
- package/README.md +77 -0
- package/dist/bridge.js +41 -11
- package/dist/bridge.js.map +1 -1
- package/dist/plugin/index.d.ts +41 -0
- package/dist/plugin/index.js +161 -0
- package/dist/plugin/index.js.map +1 -0
- package/dist/plugin/openclaw.plugin.json +55 -0
- package/dist/plugin/package.json +6 -0
- package/dist/plugin/skills/tgcc-agents/SKILL.md +161 -0
- package/dist/plugin/src/client.d.ts +167 -0
- package/dist/plugin/src/client.js +523 -0
- package/dist/plugin/src/client.js.map +1 -0
- package/dist/plugin/src/events.d.ts +44 -0
- package/dist/plugin/src/events.js +226 -0
- package/dist/plugin/src/events.js.map +1 -0
- package/dist/plugin/src/permissions.d.ts +21 -0
- package/dist/plugin/src/permissions.js +78 -0
- package/dist/plugin/src/permissions.js.map +1 -0
- package/dist/plugin/src/tools/tgcc-kill.d.ts +6 -0
- package/dist/plugin/src/tools/tgcc-kill.js +52 -0
- package/dist/plugin/src/tools/tgcc-kill.js.map +1 -0
- package/dist/plugin/src/tools/tgcc-send.d.ts +9 -0
- package/dist/plugin/src/tools/tgcc-send.js +61 -0
- package/dist/plugin/src/tools/tgcc-send.js.map +1 -0
- package/dist/plugin/src/tools/tgcc-spawn.d.ts +9 -0
- package/dist/plugin/src/tools/tgcc-spawn.js +79 -0
- package/dist/plugin/src/tools/tgcc-spawn.js.map +1 -0
- package/dist/plugin/src/tools/tgcc-status.d.ts +9 -0
- package/dist/plugin/src/tools/tgcc-status.js +74 -0
- package/dist/plugin/src/tools/tgcc-status.js.map +1 -0
- package/dist/streaming.d.ts +61 -89
- package/dist/streaming.js +672 -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 +10 -3
- package/plugin/openclaw.plugin.json +55 -0
- 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
|
-
|
|
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,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
|
-
|
|
215
|
-
|
|
216
|
-
this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(
|
|
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(
|
|
223
|
-
let
|
|
224
|
-
|
|
225
|
-
|
|
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,
|
|
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,
|
|
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
|
-
/**
|
|
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. */
|
|
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.
|
|
605
|
-
this.
|
|
606
|
-
this.
|
|
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.
|
|
666
|
+
this.clearFlushTimer();
|
|
667
|
+
for (const t of this.toolHideTimers.values())
|
|
668
|
+
clearTimeout(t);
|
|
669
|
+
this.toolHideTimers.clear();
|
|
612
670
|
}
|
|
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. */
|
|
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.
|
|
626
|
-
this.
|
|
627
|
-
this.
|
|
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: [],
|
|
654
|
-
TaskOutput: [],
|
|
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;
|
|
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');
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
857
|
-
'dispatch_agent',
|
|
858
|
-
'create_agent',
|
|
859
|
-
'AgentRunner'
|
|
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;
|
|
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';
|
|
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();
|
|
963
|
-
blockToAgent = new Map();
|
|
964
|
-
|
|
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
|
-
/**
|
|
988
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|
|
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
|
-
//
|
|
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(() => { });
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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.
|
|
1360
|
-
|
|
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
|
|
1368
|
-
|
|
1369
|
-
|
|
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
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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 ──
|