@fonz/tgcc 0.6.12 β 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.js +111 -35
- package/dist/bridge.js.map +1 -1
- package/dist/cc-process.d.ts +6 -0
- package/dist/cc-process.js +63 -0
- package/dist/cc-process.js.map +1 -1
- package/dist/cc-protocol.d.ts +30 -1
- package/dist/cc-protocol.js.map +1 -1
- package/dist/service.js +10 -4
- package/dist/service.js.map +1 -1
- package/dist/streaming.d.ts +49 -7
- package/dist/streaming.js +435 -60
- package/dist/streaming.js.map +1 -1
- package/dist/telegram.d.ts +1 -0
- package/dist/telegram.js +4 -0
- package/dist/telegram.js.map +1 -1
- package/package.json +6 -2
package/dist/streaming.js
CHANGED
|
@@ -34,6 +34,8 @@ export class StreamAccumulator {
|
|
|
34
34
|
sender;
|
|
35
35
|
editIntervalMs;
|
|
36
36
|
splitThreshold;
|
|
37
|
+
logger;
|
|
38
|
+
onError;
|
|
37
39
|
// State
|
|
38
40
|
tgMessageId = null;
|
|
39
41
|
buffer = '';
|
|
@@ -43,16 +45,21 @@ export class StreamAccumulator {
|
|
|
43
45
|
lastEditTime = 0;
|
|
44
46
|
editTimer = null;
|
|
45
47
|
thinkingIndicatorShown = false;
|
|
46
|
-
toolIndicators = [];
|
|
47
48
|
messageIds = []; // all message IDs sent during this turn
|
|
48
49
|
finished = false;
|
|
49
50
|
sendQueue = Promise.resolve();
|
|
50
51
|
turnUsage = null;
|
|
52
|
+
// Per-tool-use independent indicator messages (persists across resets)
|
|
53
|
+
toolMessages = new Map();
|
|
54
|
+
toolInputBuffers = new Map(); // tool block ID β accumulated input JSON
|
|
55
|
+
currentToolBlockId = null;
|
|
51
56
|
constructor(options) {
|
|
52
57
|
this.chatId = options.chatId;
|
|
53
58
|
this.sender = options.sender;
|
|
54
59
|
this.editIntervalMs = options.editIntervalMs ?? 1000;
|
|
55
60
|
this.splitThreshold = options.splitThreshold ?? 4000;
|
|
61
|
+
this.logger = options.logger;
|
|
62
|
+
this.onError = options.onError;
|
|
56
63
|
}
|
|
57
64
|
get allMessageIds() { return [...this.messageIds]; }
|
|
58
65
|
/** Set usage stats for the current turn (called from bridge on result event) */
|
|
@@ -91,20 +98,23 @@ export class StreamAccumulator {
|
|
|
91
98
|
if (blockType === 'thinking') {
|
|
92
99
|
this.currentBlockType = 'thinking';
|
|
93
100
|
if (!this.thinkingIndicatorShown && !this.buffer) {
|
|
94
|
-
await this.sendOrEdit('<
|
|
101
|
+
await this.sendOrEdit('<blockquote>π Thinking...</blockquote>', true);
|
|
95
102
|
this.thinkingIndicatorShown = true;
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
else if (blockType === 'text') {
|
|
99
106
|
this.currentBlockType = 'text';
|
|
100
|
-
//
|
|
101
|
-
this.
|
|
107
|
+
// Text always gets its own message β don't touch tool indicator messages
|
|
108
|
+
if (!this.tgMessageId) {
|
|
109
|
+
// Will create a new message on next sendOrEdit
|
|
110
|
+
}
|
|
102
111
|
}
|
|
103
112
|
else if (blockType === 'tool_use') {
|
|
104
113
|
this.currentBlockType = 'tool_use';
|
|
105
|
-
const
|
|
106
|
-
this.
|
|
107
|
-
|
|
114
|
+
const block = event.content_block;
|
|
115
|
+
this.currentToolBlockId = block.id;
|
|
116
|
+
// Send an independent indicator message for this tool_use block
|
|
117
|
+
await this.sendToolIndicator(block.id, block.name);
|
|
108
118
|
}
|
|
109
119
|
else if (blockType === 'image') {
|
|
110
120
|
this.currentBlockType = 'image';
|
|
@@ -130,22 +140,37 @@ export class StreamAccumulator {
|
|
|
130
140
|
this.thinkingBuffer += delta.thinking;
|
|
131
141
|
}
|
|
132
142
|
}
|
|
143
|
+
else if (this.currentBlockType === 'tool_use' && 'delta' in event) {
|
|
144
|
+
const delta = event.delta;
|
|
145
|
+
if (delta?.type === 'input_json_delta' && delta.partial_json && this.currentToolBlockId) {
|
|
146
|
+
const blockId = this.currentToolBlockId;
|
|
147
|
+
const prev = this.toolInputBuffers.get(blockId) ?? '';
|
|
148
|
+
this.toolInputBuffers.set(blockId, prev + delta.partial_json);
|
|
149
|
+
// Update indicator with input preview once we have enough
|
|
150
|
+
await this.updateToolIndicatorWithInput(blockId);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
133
153
|
else if (this.currentBlockType === 'image' && 'delta' in event) {
|
|
134
154
|
const delta = event.delta;
|
|
135
155
|
if (delta?.type === 'image_delta' && delta.data) {
|
|
136
156
|
this.imageBase64Buffer += delta.data;
|
|
137
157
|
}
|
|
138
158
|
}
|
|
139
|
-
// Ignore input_json_delta content
|
|
140
159
|
}
|
|
141
160
|
// ββ TG message management ββ
|
|
142
161
|
/** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
|
|
143
162
|
async sendOrEdit(text, rawHtml = false) {
|
|
144
|
-
this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml))
|
|
163
|
+
this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml)).catch(err => {
|
|
164
|
+
this.logger?.error?.({ err }, 'sendOrEdit failed');
|
|
165
|
+
this.onError?.(err, 'Failed to send/edit message');
|
|
166
|
+
});
|
|
145
167
|
return this.sendQueue;
|
|
146
168
|
}
|
|
147
169
|
async _doSendOrEdit(text, rawHtml = false) {
|
|
148
|
-
|
|
170
|
+
let safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
|
|
171
|
+
// Guard against HTML that has tags but no visible text content (e.g. "<b></b>")
|
|
172
|
+
if (!safeText.replace(/<[^>]*>/g, '').trim())
|
|
173
|
+
safeText = '...';
|
|
149
174
|
// Update timing BEFORE API call to prevent races
|
|
150
175
|
this.lastEditTime = Date.now();
|
|
151
176
|
try {
|
|
@@ -158,17 +183,20 @@ export class StreamAccumulator {
|
|
|
158
183
|
}
|
|
159
184
|
}
|
|
160
185
|
catch (err) {
|
|
161
|
-
|
|
162
|
-
|
|
186
|
+
const errorCode = err && typeof err === 'object' && 'error_code' in err
|
|
187
|
+
? err.error_code : 0;
|
|
188
|
+
// Handle TG rate limit (429) β retry
|
|
189
|
+
if (errorCode === 429) {
|
|
163
190
|
const retryAfter = err.parameters?.retry_after ?? 5;
|
|
164
191
|
this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
|
|
165
192
|
await sleep(retryAfter * 1000);
|
|
166
193
|
return this._doSendOrEdit(text);
|
|
167
194
|
}
|
|
168
|
-
// Ignore "message is not modified" errors
|
|
195
|
+
// Ignore "message is not modified" errors (harmless)
|
|
169
196
|
if (err instanceof Error && err.message.includes('message is not modified'))
|
|
170
197
|
return;
|
|
171
|
-
throw
|
|
198
|
+
// 400 (bad request), 403 (forbidden), and all other errors β log and skip, never throw
|
|
199
|
+
this.logger?.error?.({ err, errorCode }, 'Telegram API error in _doSendOrEdit β skipping');
|
|
172
200
|
}
|
|
173
201
|
}
|
|
174
202
|
async sendImage() {
|
|
@@ -181,16 +209,149 @@ export class StreamAccumulator {
|
|
|
181
209
|
}
|
|
182
210
|
catch (err) {
|
|
183
211
|
// Fall back to text indicator on failure
|
|
212
|
+
this.logger?.error?.({ err }, 'Failed to send image');
|
|
184
213
|
this.buffer += '\n[Image could not be sent]';
|
|
185
214
|
}
|
|
186
215
|
this.imageBase64Buffer = '';
|
|
187
216
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
217
|
+
/** Send an independent tool indicator message (not through the accumulator's sendOrEdit). */
|
|
218
|
+
async sendToolIndicator(blockId, toolName) {
|
|
219
|
+
const startTime = Date.now();
|
|
220
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
221
|
+
try {
|
|
222
|
+
const html = `<blockquote expandable>β‘ ${escapeHtml(toolName)}β¦</blockquote>`;
|
|
223
|
+
const msgId = await this.sender.sendMessage(this.chatId, html, 'HTML');
|
|
224
|
+
this.toolMessages.set(blockId, { msgId, toolName, startTime });
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
// Tool indicator is non-critical β log and continue
|
|
228
|
+
this.logger?.debug?.({ err, toolName }, 'Failed to send tool indicator');
|
|
229
|
+
}
|
|
230
|
+
}).catch(err => {
|
|
231
|
+
this.logger?.error?.({ err }, 'sendToolIndicator queue error');
|
|
232
|
+
});
|
|
233
|
+
return this.sendQueue;
|
|
234
|
+
}
|
|
235
|
+
/** Update a tool indicator message with input preview once the JSON value is complete. */
|
|
236
|
+
toolIndicatorLastSummary = new Map(); // blockId β last rendered summary
|
|
237
|
+
async updateToolIndicatorWithInput(blockId) {
|
|
238
|
+
const entry = this.toolMessages.get(blockId);
|
|
239
|
+
if (!entry)
|
|
240
|
+
return;
|
|
241
|
+
const inputJson = this.toolInputBuffers.get(blockId) ?? '';
|
|
242
|
+
// Only extract from complete JSON (try parse succeeds) or complete regex match
|
|
243
|
+
// (the value must have a closing quote to avoid truncated paths)
|
|
244
|
+
const summary = extractToolInputSummary(entry.toolName, inputJson, 120, true);
|
|
245
|
+
if (!summary)
|
|
246
|
+
return; // not enough input yet or value still streaming
|
|
247
|
+
// Skip if summary hasn't changed since last edit
|
|
248
|
+
if (this.toolIndicatorLastSummary.get(blockId) === summary)
|
|
249
|
+
return;
|
|
250
|
+
this.toolIndicatorLastSummary.set(blockId, summary);
|
|
251
|
+
const codeLine = `\n<code>${escapeHtml(summary)}</code>`;
|
|
252
|
+
const html = `<blockquote expandable>β‘ ${escapeHtml(entry.toolName)}β¦${codeLine}</blockquote>`;
|
|
253
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
254
|
+
try {
|
|
255
|
+
await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
// "message is not modified" or other edit failure β non-critical
|
|
259
|
+
this.logger?.debug?.({ err }, 'Failed to update tool indicator with input');
|
|
260
|
+
}
|
|
261
|
+
}).catch(err => {
|
|
262
|
+
this.logger?.error?.({ err }, 'updateToolIndicatorWithInput queue error');
|
|
263
|
+
});
|
|
264
|
+
return this.sendQueue;
|
|
265
|
+
}
|
|
266
|
+
/** Resolve a tool indicator with success/failure status. Edits to a compact summary with input detail. */
|
|
267
|
+
async resolveToolMessage(blockId, isError, errorMessage, resultContent) {
|
|
268
|
+
const entry = this.toolMessages.get(blockId);
|
|
269
|
+
if (!entry)
|
|
270
|
+
return;
|
|
271
|
+
const { msgId, toolName, startTime } = entry;
|
|
272
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
273
|
+
const inputJson = this.toolInputBuffers.get(blockId) ?? '';
|
|
274
|
+
const summary = extractToolInputSummary(toolName, inputJson);
|
|
275
|
+
const resultStat = resultContent ? extractToolResultStat(toolName, resultContent) : '';
|
|
276
|
+
const codeLine = summary ? `\n<code>${escapeHtml(summary)}</code>` : '';
|
|
277
|
+
const statLine = resultStat ? `\n${escapeHtml(resultStat)}` : '';
|
|
278
|
+
const icon = isError ? 'β' : 'β
';
|
|
279
|
+
const html = `<blockquote expandable>${icon} ${escapeHtml(toolName)} (${elapsed}s)${codeLine}${statLine}</blockquote>`;
|
|
280
|
+
// Clean up input buffer
|
|
281
|
+
this.toolInputBuffers.delete(blockId);
|
|
282
|
+
this.toolIndicatorLastSummary.delete(blockId);
|
|
283
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
284
|
+
try {
|
|
285
|
+
await this.sender.editMessage(this.chatId, msgId, html, 'HTML');
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
// Edit failure on resolve β non-critical
|
|
289
|
+
this.logger?.debug?.({ err, toolName }, 'Failed to resolve tool indicator');
|
|
290
|
+
}
|
|
291
|
+
}).catch(err => {
|
|
292
|
+
this.logger?.error?.({ err }, 'resolveToolMessage queue error');
|
|
293
|
+
});
|
|
294
|
+
return this.sendQueue;
|
|
295
|
+
}
|
|
296
|
+
/** Edit a specific tool indicator message by block ID. */
|
|
297
|
+
async editToolMessage(blockId, html) {
|
|
298
|
+
const entry = this.toolMessages.get(blockId);
|
|
299
|
+
if (!entry)
|
|
300
|
+
return;
|
|
301
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
302
|
+
try {
|
|
303
|
+
await this.sender.editMessage(this.chatId, entry.msgId, html, 'HTML');
|
|
304
|
+
}
|
|
305
|
+
catch (err) {
|
|
306
|
+
// Edit failure β non-critical
|
|
307
|
+
this.logger?.debug?.({ err }, 'Failed to edit tool message');
|
|
308
|
+
}
|
|
309
|
+
}).catch(err => {
|
|
310
|
+
this.logger?.error?.({ err }, 'editToolMessage queue error');
|
|
311
|
+
});
|
|
312
|
+
return this.sendQueue;
|
|
313
|
+
}
|
|
314
|
+
/** Delete a specific tool indicator message by block ID. */
|
|
315
|
+
async deleteToolMessage(blockId) {
|
|
316
|
+
const entry = this.toolMessages.get(blockId);
|
|
317
|
+
if (!entry)
|
|
318
|
+
return;
|
|
319
|
+
this.toolMessages.delete(blockId);
|
|
320
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
321
|
+
try {
|
|
322
|
+
if (this.sender.deleteMessage) {
|
|
323
|
+
await this.sender.deleteMessage(this.chatId, entry.msgId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
// Delete failure β non-critical
|
|
328
|
+
this.logger?.debug?.({ err }, 'Failed to delete tool message');
|
|
329
|
+
}
|
|
330
|
+
}).catch(err => {
|
|
331
|
+
this.logger?.error?.({ err }, 'deleteToolMessage queue error');
|
|
332
|
+
});
|
|
333
|
+
return this.sendQueue;
|
|
334
|
+
}
|
|
335
|
+
/** Delete all tool indicator messages. */
|
|
336
|
+
async deleteAllToolMessages() {
|
|
337
|
+
const ids = [...this.toolMessages.values()];
|
|
338
|
+
this.toolMessages.clear();
|
|
339
|
+
if (!this.sender.deleteMessage)
|
|
340
|
+
return;
|
|
341
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
342
|
+
for (const { msgId } of ids) {
|
|
343
|
+
try {
|
|
344
|
+
await this.sender.deleteMessage(this.chatId, msgId);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
// Delete failure β non-critical
|
|
348
|
+
this.logger?.debug?.({ err }, 'Failed to delete tool message in batch');
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}).catch(err => {
|
|
352
|
+
this.logger?.error?.({ err }, 'deleteAllToolMessages queue error');
|
|
353
|
+
});
|
|
354
|
+
return this.sendQueue;
|
|
194
355
|
}
|
|
195
356
|
async throttledEdit() {
|
|
196
357
|
const now = Date.now();
|
|
@@ -301,24 +462,24 @@ export class StreamAccumulator {
|
|
|
301
462
|
this.editTimer = null;
|
|
302
463
|
}
|
|
303
464
|
}
|
|
304
|
-
/** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message
|
|
465
|
+
/** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message.
|
|
466
|
+
* toolMessages persists across resets β they are independent of the text accumulator. */
|
|
305
467
|
softReset() {
|
|
306
468
|
this.buffer = '';
|
|
307
469
|
this.thinkingBuffer = '';
|
|
308
470
|
this.imageBase64Buffer = '';
|
|
309
471
|
this.currentBlockType = null;
|
|
472
|
+
this.currentToolBlockId = null;
|
|
310
473
|
this.lastEditTime = 0;
|
|
311
474
|
this.thinkingIndicatorShown = false;
|
|
312
|
-
this.toolIndicators = [];
|
|
313
475
|
this.finished = false;
|
|
314
476
|
this.turnUsage = null;
|
|
315
|
-
this.clearEditTimer();
|
|
477
|
+
this.clearEditTimer();
|
|
316
478
|
}
|
|
317
479
|
/** Full reset: also clears tgMessageId (next send creates a new message).
|
|
318
|
-
* Chains on the existing sendQueue so any pending finalize() edits complete first.
|
|
480
|
+
* Chains on the existing sendQueue so any pending finalize() edits complete first.
|
|
481
|
+
* toolMessages persists β they are independent fire-and-forget messages. */
|
|
319
482
|
reset() {
|
|
320
|
-
// Chain on the existing queue so pending sends (e.g. finalize) complete
|
|
321
|
-
// before the new turn starts sending.
|
|
322
483
|
const prevQueue = this.sendQueue;
|
|
323
484
|
this.softReset();
|
|
324
485
|
this.tgMessageId = null;
|
|
@@ -327,6 +488,130 @@ export class StreamAccumulator {
|
|
|
327
488
|
}
|
|
328
489
|
}
|
|
329
490
|
// ββ Helpers ββ
|
|
491
|
+
/** Extract a human-readable summary from a tool's input JSON (may be partial/incomplete). */
|
|
492
|
+
/** Shorten absolute paths to relative-ish display: /home/fonz/Botverse/KYO/src/foo.ts β KYO/src/foo.ts */
|
|
493
|
+
function shortenPath(p) {
|
|
494
|
+
// Strip common prefixes
|
|
495
|
+
return p
|
|
496
|
+
.replace(/^\/home\/[^/]+\/Botverse\//, '')
|
|
497
|
+
.replace(/^\/home\/[^/]+\/Projects\//, '')
|
|
498
|
+
.replace(/^\/home\/[^/]+\//, '~/');
|
|
499
|
+
}
|
|
500
|
+
function extractToolInputSummary(toolName, inputJson, maxLen = 120, requireComplete = false) {
|
|
501
|
+
if (!inputJson)
|
|
502
|
+
return null;
|
|
503
|
+
// Determine which field(s) to look for based on tool name
|
|
504
|
+
const fieldsByTool = {
|
|
505
|
+
Bash: ['command'],
|
|
506
|
+
Read: ['file_path', 'path'],
|
|
507
|
+
Write: ['file_path', 'path'],
|
|
508
|
+
Edit: ['file_path', 'path'],
|
|
509
|
+
MultiEdit: ['file_path', 'path'],
|
|
510
|
+
Search: ['pattern', 'query'],
|
|
511
|
+
Grep: ['pattern', 'query'],
|
|
512
|
+
Glob: ['pattern'],
|
|
513
|
+
TodoWrite: ['content', 'task', 'text'],
|
|
514
|
+
};
|
|
515
|
+
const skipTools = new Set(['TodoRead']);
|
|
516
|
+
if (skipTools.has(toolName))
|
|
517
|
+
return null;
|
|
518
|
+
const fields = fieldsByTool[toolName];
|
|
519
|
+
const isPathTool = ['Read', 'Write', 'Edit', 'MultiEdit'].includes(toolName);
|
|
520
|
+
// Try parsing complete JSON first
|
|
521
|
+
try {
|
|
522
|
+
const parsed = JSON.parse(inputJson);
|
|
523
|
+
if (fields) {
|
|
524
|
+
for (const f of fields) {
|
|
525
|
+
if (typeof parsed[f] === 'string' && parsed[f].trim()) {
|
|
526
|
+
let val = parsed[f].trim();
|
|
527
|
+
if (isPathTool)
|
|
528
|
+
val = shortenPath(val);
|
|
529
|
+
return val.length > maxLen ? val.slice(0, maxLen) + 'β¦' : val;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Default: first string value
|
|
534
|
+
for (const val of Object.values(parsed)) {
|
|
535
|
+
if (typeof val === 'string' && val.trim()) {
|
|
536
|
+
const v = val.trim();
|
|
537
|
+
return v.length > maxLen ? v.slice(0, maxLen) + 'β¦' : v;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
if (requireComplete)
|
|
544
|
+
return null; // Only use fully-parsed JSON when requireComplete
|
|
545
|
+
// Partial JSON β regex extraction (only used for final resolve, not live preview)
|
|
546
|
+
const targetFields = fields ?? ['command', 'file_path', 'path', 'pattern', 'query'];
|
|
547
|
+
for (const key of targetFields) {
|
|
548
|
+
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`, 'i'); // require closing quote
|
|
549
|
+
const m = inputJson.match(re);
|
|
550
|
+
if (m?.[1]) {
|
|
551
|
+
let val = m[1].replace(/\\n/g, ' ').replace(/\\t/g, ' ').replace(/\\"/g, '"');
|
|
552
|
+
if (isPathTool)
|
|
553
|
+
val = shortenPath(val);
|
|
554
|
+
return val.length > maxLen ? val.slice(0, maxLen) + 'β¦' : val;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Extract a compact stat from a tool result for display in the indicator. */
|
|
561
|
+
function extractToolResultStat(toolName, content) {
|
|
562
|
+
if (!content)
|
|
563
|
+
return '';
|
|
564
|
+
const first = content.split('\n')[0].trim();
|
|
565
|
+
switch (toolName) {
|
|
566
|
+
case 'Write': {
|
|
567
|
+
// "Successfully wrote 42 lines to src/foo.ts" or byte count
|
|
568
|
+
const lines = content.match(/(\d+)\s*lines?/i);
|
|
569
|
+
const bytes = content.match(/(\d[\d,.]*)\s*(bytes?|KB|MB)/i);
|
|
570
|
+
if (lines)
|
|
571
|
+
return `${lines[1]} lines`;
|
|
572
|
+
if (bytes)
|
|
573
|
+
return `${bytes[1]} ${bytes[2]}`;
|
|
574
|
+
return first.length > 60 ? first.slice(0, 60) + 'β¦' : first;
|
|
575
|
+
}
|
|
576
|
+
case 'Edit':
|
|
577
|
+
case 'MultiEdit': {
|
|
578
|
+
// "Edited src/foo.ts: replaced 3 lines" or snippet
|
|
579
|
+
const replaced = content.match(/replaced\s+(\d+)\s*lines?/i);
|
|
580
|
+
const chars = content.match(/(\d+)\s*characters?/i);
|
|
581
|
+
if (replaced)
|
|
582
|
+
return `${replaced[1]} lines replaced`;
|
|
583
|
+
if (chars)
|
|
584
|
+
return `${chars[1]} chars changed`;
|
|
585
|
+
return first.length > 60 ? first.slice(0, 60) + 'β¦' : first;
|
|
586
|
+
}
|
|
587
|
+
case 'Bash': {
|
|
588
|
+
// Show exit code or first line of output (truncated)
|
|
589
|
+
const exit = content.match(/exit\s*code\s*[:=]?\s*(\d+)/i);
|
|
590
|
+
if (exit && exit[1] !== '0')
|
|
591
|
+
return `exit ${exit[1]}`;
|
|
592
|
+
// Count output lines
|
|
593
|
+
const outputLines = content.split('\n').filter(l => l.trim()).length;
|
|
594
|
+
if (outputLines > 3)
|
|
595
|
+
return `${outputLines} lines output`;
|
|
596
|
+
return first.length > 60 ? first.slice(0, 60) + 'β¦' : first;
|
|
597
|
+
}
|
|
598
|
+
case 'Read': {
|
|
599
|
+
const lines = content.split('\n').length;
|
|
600
|
+
return `${lines} lines`;
|
|
601
|
+
}
|
|
602
|
+
case 'Search':
|
|
603
|
+
case 'Grep':
|
|
604
|
+
case 'Glob': {
|
|
605
|
+
const matches = content.match(/(\d+)\s*(match|result|file)/i);
|
|
606
|
+
if (matches)
|
|
607
|
+
return `${matches[1]} ${matches[2]}s`;
|
|
608
|
+
const resultLines = content.split('\n').filter(l => l.trim()).length;
|
|
609
|
+
return `${resultLines} results`;
|
|
610
|
+
}
|
|
611
|
+
default:
|
|
612
|
+
return first.length > 60 ? first.slice(0, 60) + 'β¦' : first;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
330
615
|
function findSplitPoint(text, threshold) {
|
|
331
616
|
// Try to split at paragraph break
|
|
332
617
|
const paragraphBreak = text.lastIndexOf('\n\n', threshold);
|
|
@@ -397,36 +682,46 @@ export function extractSubAgentSummary(jsonInput, maxLen = 150) {
|
|
|
397
682
|
}
|
|
398
683
|
return '';
|
|
399
684
|
}
|
|
685
|
+
/** Label fields in priority order (index 0 = highest priority). */
|
|
686
|
+
const LABEL_FIELDS = ['name', 'description', 'subagent_type', 'team_name'];
|
|
687
|
+
/** Priority index for a label source field. Lower = better. */
|
|
688
|
+
export function labelFieldPriority(field) {
|
|
689
|
+
if (!field)
|
|
690
|
+
return LABEL_FIELDS.length + 1; // worst
|
|
691
|
+
const idx = LABEL_FIELDS.indexOf(field);
|
|
692
|
+
return idx >= 0 ? idx : LABEL_FIELDS.length;
|
|
693
|
+
}
|
|
400
694
|
/**
|
|
401
695
|
* Extract a human-readable label for a sub-agent from its JSON tool input.
|
|
402
|
-
*
|
|
403
|
-
*
|
|
696
|
+
* Returns { label, field } so callers can track priority and upgrade labels
|
|
697
|
+
* when higher-priority fields become available during streaming.
|
|
404
698
|
*/
|
|
405
699
|
export function extractAgentLabel(jsonInput) {
|
|
406
|
-
// Priority order of CC Task tool fields
|
|
407
|
-
const labelFields = ['name', 'description', 'subagent_type', 'team_name'];
|
|
408
700
|
const summaryField = 'prompt'; // last resort β first line of prompt
|
|
409
701
|
try {
|
|
410
702
|
const parsed = JSON.parse(jsonInput);
|
|
411
|
-
for (const key of
|
|
703
|
+
for (const key of LABEL_FIELDS) {
|
|
412
704
|
const val = parsed[key];
|
|
413
705
|
if (typeof val === 'string' && val.trim()) {
|
|
414
|
-
return val.trim().slice(0, 80);
|
|
706
|
+
return { label: val.trim().slice(0, 80), field: key };
|
|
415
707
|
}
|
|
416
708
|
}
|
|
417
709
|
if (typeof parsed[summaryField] === 'string' && parsed[summaryField].trim()) {
|
|
418
710
|
const firstLine = parsed[summaryField].trim().split('\n')[0];
|
|
419
|
-
|
|
711
|
+
const label = firstLine.length > 60 ? firstLine.slice(0, 60) + 'β¦' : firstLine;
|
|
712
|
+
return { label, field: 'prompt' };
|
|
420
713
|
}
|
|
421
|
-
return '';
|
|
714
|
+
return { label: '', field: null };
|
|
422
715
|
}
|
|
423
716
|
catch {
|
|
424
717
|
// JSON incomplete during streaming β extract first complete field value
|
|
425
|
-
|
|
718
|
+
const result = extractFieldFromPartialJsonWithField(jsonInput, LABEL_FIELDS);
|
|
719
|
+
return result ?? { label: '', field: null };
|
|
426
720
|
}
|
|
427
721
|
}
|
|
428
|
-
/** Extract the first complete string value for any of the given keys from partial JSON.
|
|
429
|
-
|
|
722
|
+
/** Extract the first complete string value for any of the given keys from partial JSON.
|
|
723
|
+
* Returns { label, field } so callers know which field matched. */
|
|
724
|
+
function extractFieldFromPartialJsonWithField(input, keys) {
|
|
430
725
|
for (const key of keys) {
|
|
431
726
|
const idx = input.indexOf(`"${key}"`);
|
|
432
727
|
if (idx === -1)
|
|
@@ -447,7 +742,7 @@ function extractFieldFromPartialJson(input, keys) {
|
|
|
447
742
|
}
|
|
448
743
|
else if (afterColon[i] === '"') {
|
|
449
744
|
if (value.trim())
|
|
450
|
-
return value.trim().slice(0, 80);
|
|
745
|
+
return { label: value.trim().slice(0, 80), field: key };
|
|
451
746
|
break;
|
|
452
747
|
}
|
|
453
748
|
else {
|
|
@@ -500,8 +795,10 @@ export class SubAgentTracker {
|
|
|
500
795
|
try {
|
|
501
796
|
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
502
797
|
}
|
|
503
|
-
catch {
|
|
504
|
-
|
|
798
|
+
catch (err) {
|
|
799
|
+
// Non-critical β edit failure on dispatched agent status
|
|
800
|
+
}
|
|
801
|
+
}).catch(() => { });
|
|
505
802
|
}
|
|
506
803
|
}
|
|
507
804
|
async handleEvent(event) {
|
|
@@ -581,9 +878,9 @@ export class SubAgentTracker {
|
|
|
581
878
|
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
582
879
|
}
|
|
583
880
|
catch {
|
|
584
|
-
//
|
|
881
|
+
// Edit failure on tool result β non-critical
|
|
585
882
|
}
|
|
586
|
-
});
|
|
883
|
+
}).catch(() => { });
|
|
587
884
|
await this.sendQueue;
|
|
588
885
|
}
|
|
589
886
|
async onBlockStart(event) {
|
|
@@ -599,6 +896,7 @@ export class SubAgentTracker {
|
|
|
599
896
|
tgMessageId: null,
|
|
600
897
|
status: 'running',
|
|
601
898
|
label: '',
|
|
899
|
+
labelField: null,
|
|
602
900
|
agentName: '',
|
|
603
901
|
inputPreview: '',
|
|
604
902
|
dispatchedAt: null,
|
|
@@ -619,9 +917,9 @@ export class SubAgentTracker {
|
|
|
619
917
|
this.consolidatedAgentMsgId = msgId;
|
|
620
918
|
}
|
|
621
919
|
catch {
|
|
622
|
-
//
|
|
920
|
+
// Sub-agent indicator is non-critical
|
|
623
921
|
}
|
|
624
|
-
});
|
|
922
|
+
}).catch(() => { });
|
|
625
923
|
await this.sendQueue;
|
|
626
924
|
}
|
|
627
925
|
}
|
|
@@ -643,8 +941,10 @@ export class SubAgentTracker {
|
|
|
643
941
|
try {
|
|
644
942
|
await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
|
|
645
943
|
}
|
|
646
|
-
catch {
|
|
647
|
-
|
|
944
|
+
catch {
|
|
945
|
+
// Consolidated message edit failure β non-critical
|
|
946
|
+
}
|
|
947
|
+
}).catch(() => { });
|
|
648
948
|
}
|
|
649
949
|
async onInputDelta(event) {
|
|
650
950
|
const toolUseId = this.blockToAgent.get(event.index);
|
|
@@ -671,13 +971,13 @@ export class SubAgentTracker {
|
|
|
671
971
|
info.agentName = nameMatch[1];
|
|
672
972
|
}
|
|
673
973
|
}
|
|
674
|
-
// Try to extract an agent label
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
974
|
+
// Try to extract an agent label β keep trying for higher-priority fields
|
|
975
|
+
// (e.g., upgrade from subagent_type "general-purpose" to description "Fix the bug")
|
|
976
|
+
const extracted = extractAgentLabel(info.inputPreview);
|
|
977
|
+
if (extracted.label && labelFieldPriority(extracted.field) < labelFieldPriority(info.labelField)) {
|
|
978
|
+
info.label = extracted.label;
|
|
979
|
+
info.labelField = extracted.field;
|
|
980
|
+
this.updateConsolidatedAgentMessage();
|
|
681
981
|
}
|
|
682
982
|
}
|
|
683
983
|
async onBlockStop(event) {
|
|
@@ -690,11 +990,11 @@ export class SubAgentTracker {
|
|
|
690
990
|
// content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
|
|
691
991
|
info.status = 'dispatched';
|
|
692
992
|
info.dispatchedAt = Date.now();
|
|
693
|
-
// Final chance to extract label from complete input
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
993
|
+
// Final chance to extract label from complete input (may upgrade to higher-priority field)
|
|
994
|
+
const finalExtracted = extractAgentLabel(info.inputPreview);
|
|
995
|
+
if (finalExtracted.label && labelFieldPriority(finalExtracted.field) < labelFieldPriority(info.labelField)) {
|
|
996
|
+
info.label = finalExtracted.label;
|
|
997
|
+
info.labelField = finalExtracted.field;
|
|
698
998
|
}
|
|
699
999
|
this.updateConsolidatedAgentMessage();
|
|
700
1000
|
// Start elapsed timer β update every 15s to show progress
|
|
@@ -786,8 +1086,10 @@ export class SubAgentTracker {
|
|
|
786
1086
|
try {
|
|
787
1087
|
await this.sender.setReaction?.(this.chatId, msgId, emoji);
|
|
788
1088
|
}
|
|
789
|
-
catch {
|
|
790
|
-
|
|
1089
|
+
catch {
|
|
1090
|
+
// Reaction failure β non-critical, might not be supported
|
|
1091
|
+
}
|
|
1092
|
+
}).catch(() => { });
|
|
791
1093
|
}
|
|
792
1094
|
// Check if ALL dispatched agents are now completed
|
|
793
1095
|
if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
|
|
@@ -815,6 +1117,79 @@ export class SubAgentTracker {
|
|
|
815
1117
|
}
|
|
816
1118
|
return null;
|
|
817
1119
|
}
|
|
1120
|
+
/** Handle a system task_started event β update the sub-agent status display. */
|
|
1121
|
+
handleTaskStarted(toolUseId, description, taskType) {
|
|
1122
|
+
const info = this.agents.get(toolUseId);
|
|
1123
|
+
if (!info)
|
|
1124
|
+
return;
|
|
1125
|
+
// Use task_started description as label if we don't have one yet or current is low-priority
|
|
1126
|
+
if (description && labelFieldPriority('description') < labelFieldPriority(info.labelField)) {
|
|
1127
|
+
info.label = description.slice(0, 80);
|
|
1128
|
+
info.labelField = 'description';
|
|
1129
|
+
}
|
|
1130
|
+
info.status = 'dispatched';
|
|
1131
|
+
if (!info.dispatchedAt)
|
|
1132
|
+
info.dispatchedAt = Date.now();
|
|
1133
|
+
this.updateConsolidatedAgentMessage();
|
|
1134
|
+
}
|
|
1135
|
+
/** Handle a system task_progress event β update the sub-agent status with current activity. */
|
|
1136
|
+
handleTaskProgress(toolUseId, description, lastToolName) {
|
|
1137
|
+
const info = this.agents.get(toolUseId);
|
|
1138
|
+
if (!info)
|
|
1139
|
+
return;
|
|
1140
|
+
if (info.status === 'completed')
|
|
1141
|
+
return; // Don't update completed agents
|
|
1142
|
+
// Update the consolidated message with progress info
|
|
1143
|
+
this.updateConsolidatedAgentMessageWithProgress(toolUseId, description, lastToolName);
|
|
1144
|
+
}
|
|
1145
|
+
/** Handle a system task_completed event. */
|
|
1146
|
+
handleTaskCompleted(toolUseId) {
|
|
1147
|
+
const info = this.agents.get(toolUseId);
|
|
1148
|
+
if (!info || info.status === 'completed')
|
|
1149
|
+
return;
|
|
1150
|
+
info.status = 'completed';
|
|
1151
|
+
this.updateConsolidatedAgentMessage();
|
|
1152
|
+
// Check if all agents are done
|
|
1153
|
+
const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
|
|
1154
|
+
if (allDone && this.onAllReported) {
|
|
1155
|
+
this.onAllReported();
|
|
1156
|
+
this.stopMailboxWatch();
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/** Build and edit the shared sub-agent status message with progress info. */
|
|
1160
|
+
updateConsolidatedAgentMessageWithProgress(progressToolUseId, progressDesc, lastTool) {
|
|
1161
|
+
if (!this.consolidatedAgentMsgId)
|
|
1162
|
+
return;
|
|
1163
|
+
const msgId = this.consolidatedAgentMsgId;
|
|
1164
|
+
const lines = [];
|
|
1165
|
+
for (const info of this.agents.values()) {
|
|
1166
|
+
const label = info.label || info.agentName || info.toolName;
|
|
1167
|
+
if (info.toolUseId === progressToolUseId && info.status !== 'completed') {
|
|
1168
|
+
const toolInfo = lastTool ? ` (${lastTool})` : '';
|
|
1169
|
+
const desc = progressDesc ? `: ${progressDesc.slice(0, 60)}` : '';
|
|
1170
|
+
lines.push(`π€ ${escapeHtml(label)} β Working${toolInfo}${desc}`);
|
|
1171
|
+
}
|
|
1172
|
+
else {
|
|
1173
|
+
const status = info.status === 'completed' ? 'β
Done'
|
|
1174
|
+
: info.status === 'dispatched' ? 'Waiting for resultsβ¦'
|
|
1175
|
+
: 'Workingβ¦';
|
|
1176
|
+
lines.push(`π€ ${escapeHtml(label)} β ${status}`);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const text = lines.join('\n');
|
|
1180
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
1181
|
+
try {
|
|
1182
|
+
await this.sender.editMessage(this.chatId, msgId, text, 'HTML');
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
// Progress message edit failure β non-critical
|
|
1186
|
+
}
|
|
1187
|
+
}).catch(() => { });
|
|
1188
|
+
}
|
|
1189
|
+
/** Find a tracked sub-agent by tool_use_id. */
|
|
1190
|
+
getAgentByToolUseId(toolUseId) {
|
|
1191
|
+
return this.agents.get(toolUseId);
|
|
1192
|
+
}
|
|
818
1193
|
reset() {
|
|
819
1194
|
// Stop mailbox watching
|
|
820
1195
|
this.stopMailboxWatch();
|