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