@fonz/tgcc 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bridge.js +151 -23
- package/dist/bridge.js.map +1 -1
- package/dist/cc-process.d.ts +1 -1
- package/dist/cc-process.js +32 -3
- package/dist/cc-process.js.map +1 -1
- package/dist/cc-protocol.d.ts +29 -1
- package/dist/cc-protocol.js +1 -0
- package/dist/cc-protocol.js.map +1 -1
- package/dist/cli.js +168 -19
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/service.d.ts +1 -1
- package/dist/service.js +1 -1
- package/dist/service.js.map +1 -1
- package/dist/session.js +6 -3
- package/dist/session.js.map +1 -1
- package/dist/streaming.d.ts +75 -9
- package/dist/streaming.js +407 -104
- package/dist/streaming.js.map +1 -1
- package/dist/telegram-html.d.ts +5 -0
- package/dist/telegram-html.js +120 -0
- package/dist/telegram-html.js.map +1 -0
- package/dist/telegram.d.ts +1 -0
- package/dist/telegram.js +4 -0
- package/dist/telegram.js.map +1 -1
- package/package.json +9 -1
package/dist/streaming.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { readFileSync, watchFile, unwatchFile, existsSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { markdownToTelegramHtml } from './telegram-html.js';
|
|
1
5
|
// ── HTML safety & conversion ──
|
|
2
6
|
/** Escape characters that are special in Telegram HTML. */
|
|
3
7
|
export function escapeHtml(text) {
|
|
@@ -7,43 +11,11 @@ export function escapeHtml(text) {
|
|
|
7
11
|
.replace(/>/g, '>');
|
|
8
12
|
}
|
|
9
13
|
/**
|
|
10
|
-
* Convert markdown
|
|
11
|
-
*
|
|
12
|
-
* Falls back to HTML-escaped plain text for anything it can't convert.
|
|
14
|
+
* Convert markdown text to Telegram-safe HTML using the marked library.
|
|
15
|
+
* Replaces the old hand-rolled implementation with a proper markdown parser.
|
|
13
16
|
*/
|
|
14
17
|
export function markdownToHtml(text) {
|
|
15
|
-
|
|
16
|
-
const codeBlocks = [];
|
|
17
|
-
let result = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
|
|
18
|
-
const idx = codeBlocks.length;
|
|
19
|
-
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : '';
|
|
20
|
-
codeBlocks.push(`<pre><code${langAttr}>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
|
21
|
-
return `\x00CODEBLOCK${idx}\x00`;
|
|
22
|
-
});
|
|
23
|
-
// Extract inline code
|
|
24
|
-
const inlineCodes = [];
|
|
25
|
-
result = result.replace(/`([^`\n]+)`/g, (_match, code) => {
|
|
26
|
-
const idx = inlineCodes.length;
|
|
27
|
-
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
|
28
|
-
return `\x00INLINE${idx}\x00`;
|
|
29
|
-
});
|
|
30
|
-
// Escape HTML in remaining text
|
|
31
|
-
result = escapeHtml(result);
|
|
32
|
-
// Convert markdown formatting
|
|
33
|
-
// Bold: **text** or __text__
|
|
34
|
-
result = result.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>');
|
|
35
|
-
result = result.replace(/__(.+?)__/g, '<b>$1</b>');
|
|
36
|
-
// Italic: *text* or _text_ (but not inside words for underscore)
|
|
37
|
-
result = result.replace(/(?<!\w)\*([^*\n]+?)\*(?!\w)/g, '<i>$1</i>');
|
|
38
|
-
result = result.replace(/(?<!\w)_([^_\n]+?)_(?!\w)/g, '<i>$1</i>');
|
|
39
|
-
// Strikethrough: ~~text~~
|
|
40
|
-
result = result.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
41
|
-
// Links: [text](url)
|
|
42
|
-
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
43
|
-
// Restore code blocks and inline code
|
|
44
|
-
result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_match, idx) => codeBlocks[Number(idx)]);
|
|
45
|
-
result = result.replace(/\x00INLINE(\d+)\x00/g, (_match, idx) => inlineCodes[Number(idx)]);
|
|
46
|
-
return result;
|
|
18
|
+
return markdownToTelegramHtml(text);
|
|
47
19
|
}
|
|
48
20
|
/**
|
|
49
21
|
* Make text safe for Telegram HTML parse mode during streaming.
|
|
@@ -91,8 +63,7 @@ export class StreamAccumulator {
|
|
|
91
63
|
async handleEvent(event) {
|
|
92
64
|
switch (event.type) {
|
|
93
65
|
case 'message_start':
|
|
94
|
-
//
|
|
95
|
-
this.softReset();
|
|
66
|
+
// Bridge handles reset decision - no automatic reset here
|
|
96
67
|
break;
|
|
97
68
|
case 'content_block_start':
|
|
98
69
|
await this.onContentBlockStart(event);
|
|
@@ -170,6 +141,8 @@ export class StreamAccumulator {
|
|
|
170
141
|
}
|
|
171
142
|
async _doSendOrEdit(text, rawHtml = false) {
|
|
172
143
|
const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
|
|
144
|
+
// Update timing BEFORE API call to prevent races
|
|
145
|
+
this.lastEditTime = Date.now();
|
|
173
146
|
try {
|
|
174
147
|
if (!this.tgMessageId) {
|
|
175
148
|
this.tgMessageId = await this.sender.sendMessage(this.chatId, safeText, 'HTML');
|
|
@@ -178,7 +151,6 @@ export class StreamAccumulator {
|
|
|
178
151
|
else {
|
|
179
152
|
await this.sender.editMessage(this.chatId, this.tgMessageId, safeText, 'HTML');
|
|
180
153
|
}
|
|
181
|
-
this.lastEditTime = Date.now();
|
|
182
154
|
}
|
|
183
155
|
catch (err) {
|
|
184
156
|
// Handle TG rate limit (429)
|
|
@@ -233,32 +205,35 @@ export class StreamAccumulator {
|
|
|
233
205
|
}, delay);
|
|
234
206
|
}
|
|
235
207
|
}
|
|
236
|
-
/** Build the full message text including thinking blockquote prefix and usage footer
|
|
208
|
+
/** Build the full message text including thinking blockquote prefix and usage footer.
|
|
209
|
+
* Returns { text, hasHtmlSuffix } — caller must pass rawHtml=true when hasHtmlSuffix is set
|
|
210
|
+
* because the footer contains pre-formatted HTML (<i> tags).
|
|
211
|
+
*/
|
|
237
212
|
buildFullText(includeSuffix = false) {
|
|
238
213
|
let text = '';
|
|
239
214
|
if (this.thinkingBuffer) {
|
|
240
|
-
// Truncate thinking to 1024 chars max for the expandable blockquote
|
|
241
215
|
const thinkingPreview = this.thinkingBuffer.length > 1024
|
|
242
216
|
? this.thinkingBuffer.slice(0, 1024) + '…'
|
|
243
217
|
: this.thinkingBuffer;
|
|
244
218
|
text += `<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>\n`;
|
|
245
219
|
}
|
|
246
|
-
|
|
220
|
+
// Convert markdown buffer to HTML-safe text
|
|
221
|
+
text += makeHtmlSafe(this.buffer);
|
|
247
222
|
if (includeSuffix && this.turnUsage) {
|
|
248
223
|
text += '\n' + formatUsageFooter(this.turnUsage);
|
|
249
224
|
}
|
|
250
|
-
return text;
|
|
225
|
+
return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
|
|
251
226
|
}
|
|
252
227
|
async doEdit() {
|
|
253
228
|
if (!this.buffer)
|
|
254
229
|
return;
|
|
255
|
-
const
|
|
230
|
+
const { text, hasHtmlSuffix } = this.buildFullText();
|
|
256
231
|
// Check if we need to split
|
|
257
|
-
if (
|
|
232
|
+
if (text.length > this.splitThreshold) {
|
|
258
233
|
await this.splitMessage();
|
|
259
234
|
return;
|
|
260
235
|
}
|
|
261
|
-
await this.sendOrEdit(
|
|
236
|
+
await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
|
|
262
237
|
}
|
|
263
238
|
async splitMessage() {
|
|
264
239
|
// Find a good split point near the threshold
|
|
@@ -266,12 +241,12 @@ export class StreamAccumulator {
|
|
|
266
241
|
const firstPart = this.buffer.slice(0, splitAt);
|
|
267
242
|
const remainder = this.buffer.slice(splitAt);
|
|
268
243
|
// Finalize current message with first part
|
|
269
|
-
await this.sendOrEdit(firstPart);
|
|
244
|
+
await this.sendOrEdit(makeHtmlSafe(firstPart), true);
|
|
270
245
|
// Start a new message for remainder
|
|
271
246
|
this.tgMessageId = null;
|
|
272
247
|
this.buffer = remainder;
|
|
273
248
|
if (this.buffer) {
|
|
274
|
-
await this.sendOrEdit(this.buffer);
|
|
249
|
+
await this.sendOrEdit(makeHtmlSafe(this.buffer), true);
|
|
275
250
|
}
|
|
276
251
|
}
|
|
277
252
|
async finalize() {
|
|
@@ -283,8 +258,8 @@ export class StreamAccumulator {
|
|
|
283
258
|
}
|
|
284
259
|
if (this.buffer) {
|
|
285
260
|
// Final edit with complete text including thinking blockquote and usage footer
|
|
286
|
-
const
|
|
287
|
-
await this.sendOrEdit(
|
|
261
|
+
const { text } = this.buildFullText(true);
|
|
262
|
+
await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
|
|
288
263
|
}
|
|
289
264
|
else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
|
|
290
265
|
// Only thinking happened, no text — show thinking as expandable blockquote
|
|
@@ -294,6 +269,12 @@ export class StreamAccumulator {
|
|
|
294
269
|
await this.sendOrEdit(`<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>`, true);
|
|
295
270
|
}
|
|
296
271
|
}
|
|
272
|
+
clearEditTimer() {
|
|
273
|
+
if (this.editTimer) {
|
|
274
|
+
clearTimeout(this.editTimer);
|
|
275
|
+
this.editTimer = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
297
278
|
/** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
|
|
298
279
|
softReset() {
|
|
299
280
|
this.buffer = '';
|
|
@@ -305,10 +286,7 @@ export class StreamAccumulator {
|
|
|
305
286
|
this.toolIndicators = [];
|
|
306
287
|
this.finished = false;
|
|
307
288
|
this.turnUsage = null;
|
|
308
|
-
|
|
309
|
-
clearTimeout(this.editTimer);
|
|
310
|
-
this.editTimer = null;
|
|
311
|
-
}
|
|
289
|
+
this.clearEditTimer(); // Ensure cleanup
|
|
312
290
|
}
|
|
313
291
|
/** Full reset: also clears tgMessageId (next send creates a new message) */
|
|
314
292
|
reset() {
|
|
@@ -356,25 +334,145 @@ export function formatUsageFooter(usage) {
|
|
|
356
334
|
return `<i>${parts.join(' · ')}</i>`;
|
|
357
335
|
}
|
|
358
336
|
// ── Sub-agent detection patterns ──
|
|
359
|
-
const
|
|
337
|
+
const CC_SUB_AGENT_TOOLS = new Set([
|
|
338
|
+
'Task', // Primary CC spawning tool
|
|
339
|
+
'dispatch_agent', // Legacy/alternative tool
|
|
340
|
+
'create_agent', // Test compatibility
|
|
341
|
+
'AgentRunner' // Test compatibility
|
|
342
|
+
]);
|
|
360
343
|
export function isSubAgentTool(toolName) {
|
|
361
|
-
return
|
|
344
|
+
return CC_SUB_AGENT_TOOLS.has(toolName);
|
|
345
|
+
}
|
|
346
|
+
/** Extract a human-readable summary from partial/complete JSON tool input.
|
|
347
|
+
* Looks for prompt, task, command, description fields — returns the first found, truncated.
|
|
348
|
+
*/
|
|
349
|
+
export function extractSubAgentSummary(jsonInput, maxLen = 150) {
|
|
350
|
+
try {
|
|
351
|
+
const parsed = JSON.parse(jsonInput);
|
|
352
|
+
const value = parsed.prompt || parsed.task || parsed.command || parsed.description || parsed.message || '';
|
|
353
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
354
|
+
return value.length > maxLen ? value.slice(0, maxLen) + '…' : value;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// Partial JSON — try regex extraction for common patterns
|
|
359
|
+
for (const key of ['prompt', 'task', 'command', 'description', 'message']) {
|
|
360
|
+
const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'i');
|
|
361
|
+
const m = jsonInput.match(re);
|
|
362
|
+
if (m?.[1]) {
|
|
363
|
+
const val = m[1].replace(/\\n/g, ' ').replace(/\\"/g, '"');
|
|
364
|
+
return val.length > maxLen ? val.slice(0, maxLen) + '…' : val;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return '';
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Extract a human-readable label for a sub-agent from its JSON tool input.
|
|
372
|
+
* Uses CC's Task tool structured fields: name, description, subagent_type, team_name.
|
|
373
|
+
* No regex guessing — purely structural JSON field extraction.
|
|
374
|
+
*/
|
|
375
|
+
export function extractAgentLabel(jsonInput) {
|
|
376
|
+
// Priority order of CC Task tool fields
|
|
377
|
+
const labelFields = ['name', 'description', 'subagent_type', 'team_name'];
|
|
378
|
+
const summaryField = 'prompt'; // last resort — first line of prompt
|
|
379
|
+
try {
|
|
380
|
+
const parsed = JSON.parse(jsonInput);
|
|
381
|
+
for (const key of labelFields) {
|
|
382
|
+
const val = parsed[key];
|
|
383
|
+
if (typeof val === 'string' && val.trim()) {
|
|
384
|
+
return val.trim().slice(0, 80);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (typeof parsed[summaryField] === 'string' && parsed[summaryField].trim()) {
|
|
388
|
+
const firstLine = parsed[summaryField].trim().split('\n')[0];
|
|
389
|
+
return firstLine.length > 60 ? firstLine.slice(0, 60) + '…' : firstLine;
|
|
390
|
+
}
|
|
391
|
+
return '';
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
// JSON incomplete during streaming — extract first complete field value
|
|
395
|
+
return extractFieldFromPartialJson(jsonInput, labelFields) ?? '';
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** Extract the first complete string value for any of the given keys from partial JSON. */
|
|
399
|
+
function extractFieldFromPartialJson(input, keys) {
|
|
400
|
+
for (const key of keys) {
|
|
401
|
+
const idx = input.indexOf(`"${key}"`);
|
|
402
|
+
if (idx === -1)
|
|
403
|
+
continue;
|
|
404
|
+
const afterKey = input.slice(idx + key.length + 2);
|
|
405
|
+
const colonIdx = afterKey.indexOf(':');
|
|
406
|
+
if (colonIdx === -1)
|
|
407
|
+
continue;
|
|
408
|
+
const afterColon = afterKey.slice(colonIdx + 1).trimStart();
|
|
409
|
+
if (!afterColon.startsWith('"'))
|
|
410
|
+
continue;
|
|
411
|
+
// Walk the string handling escapes
|
|
412
|
+
let i = 1, value = '';
|
|
413
|
+
while (i < afterColon.length) {
|
|
414
|
+
if (afterColon[i] === '\\' && i + 1 < afterColon.length) {
|
|
415
|
+
value += afterColon[i + 1] === 'n' ? ' ' : afterColon[i + 1];
|
|
416
|
+
i += 2;
|
|
417
|
+
}
|
|
418
|
+
else if (afterColon[i] === '"') {
|
|
419
|
+
if (value.trim())
|
|
420
|
+
return value.trim().slice(0, 80);
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
value += afterColon[i];
|
|
425
|
+
i++;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return null;
|
|
362
430
|
}
|
|
363
431
|
export class SubAgentTracker {
|
|
364
432
|
chatId;
|
|
365
433
|
sender;
|
|
366
|
-
getMainMessageId;
|
|
367
434
|
agents = new Map(); // toolUseId → info
|
|
368
435
|
blockToAgent = new Map(); // blockIndex → toolUseId
|
|
369
436
|
sendQueue = Promise.resolve();
|
|
437
|
+
teamName = null;
|
|
438
|
+
mailboxPath = null;
|
|
439
|
+
mailboxWatching = false;
|
|
440
|
+
lastMailboxCount = 0;
|
|
441
|
+
onAllReported = null;
|
|
442
|
+
hasPendingFollowUp = false;
|
|
370
443
|
constructor(options) {
|
|
371
444
|
this.chatId = options.chatId;
|
|
372
445
|
this.sender = options.sender;
|
|
373
|
-
this.getMainMessageId = options.getMainMessageId;
|
|
374
446
|
}
|
|
375
447
|
get activeAgents() {
|
|
376
448
|
return [...this.agents.values()];
|
|
377
449
|
}
|
|
450
|
+
/** Returns true if any sub-agents were tracked in this turn (including completed ones) */
|
|
451
|
+
get hadSubAgents() {
|
|
452
|
+
return this.agents.size > 0;
|
|
453
|
+
}
|
|
454
|
+
/** Returns true if any sub-agents are in dispatched state (spawned but no result yet) */
|
|
455
|
+
get hasDispatchedAgents() {
|
|
456
|
+
return [...this.agents.values()].some(a => a.status === 'dispatched');
|
|
457
|
+
}
|
|
458
|
+
/** Mark all dispatched agents as completed — used when CC reports results
|
|
459
|
+
* in its main text response rather than via tool_result events.
|
|
460
|
+
*/
|
|
461
|
+
markDispatchedAsReportedInMain() {
|
|
462
|
+
for (const [, info] of this.agents) {
|
|
463
|
+
if (info.status !== 'dispatched' || !info.tgMessageId)
|
|
464
|
+
continue;
|
|
465
|
+
info.status = 'completed';
|
|
466
|
+
const label = info.label || info.toolName;
|
|
467
|
+
const text = `✅ ${escapeHtml(label)} — see main message`;
|
|
468
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
469
|
+
try {
|
|
470
|
+
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
471
|
+
}
|
|
472
|
+
catch { /* ignore */ }
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
378
476
|
async handleEvent(event) {
|
|
379
477
|
switch (event.type) {
|
|
380
478
|
case 'content_block_start':
|
|
@@ -390,11 +488,80 @@ export class SubAgentTracker {
|
|
|
390
488
|
case 'content_block_stop':
|
|
391
489
|
await this.onBlockStop(event);
|
|
392
490
|
break;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
491
|
+
// NOTE: message_start reset is handled by the bridge (not here)
|
|
492
|
+
// so it can check hadSubAgents before clearing state
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** Handle a tool_result event — marks the sub-agent as completed with collapsible result */
|
|
496
|
+
/** Set agent metadata from structured tool_use_result */
|
|
497
|
+
setAgentMetadata(toolUseId, meta) {
|
|
498
|
+
const info = this.agents.get(toolUseId);
|
|
499
|
+
if (!info)
|
|
500
|
+
return;
|
|
501
|
+
if (meta.agentName)
|
|
502
|
+
info.agentName = meta.agentName;
|
|
503
|
+
}
|
|
504
|
+
/** Mark an agent as completed externally (e.g. from bridge follow-up) */
|
|
505
|
+
markCompleted(toolUseId, _reason) {
|
|
506
|
+
const info = this.agents.get(toolUseId);
|
|
507
|
+
if (!info || info.status === 'completed')
|
|
508
|
+
return;
|
|
509
|
+
info.status = 'completed';
|
|
510
|
+
// Check if all agents are done
|
|
511
|
+
const allDone = ![...this.agents.values()].some(a => a.status === 'dispatched');
|
|
512
|
+
if (allDone && this.onAllReported) {
|
|
513
|
+
this.onAllReported();
|
|
514
|
+
this.stopMailboxWatch();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async handleToolResult(toolUseId, result) {
|
|
518
|
+
const info = this.agents.get(toolUseId);
|
|
519
|
+
if (!info || !info.tgMessageId)
|
|
520
|
+
return;
|
|
521
|
+
// Detect background agent spawn confirmations — keep as dispatched, don't mark completed
|
|
522
|
+
// Spawn confirmations contain "agent_id:" and "Spawned" patterns
|
|
523
|
+
const isSpawnConfirmation = /agent_id:\s*\S+@\S+/.test(result) || /[Ss]pawned\s+successfully/i.test(result);
|
|
524
|
+
if (isSpawnConfirmation) {
|
|
525
|
+
// Extract agent name from spawn confirmation for mailbox matching
|
|
526
|
+
const nameMatch = result.match(/name:\s*(\S+)/);
|
|
527
|
+
if (nameMatch && !info.agentName)
|
|
528
|
+
info.agentName = nameMatch[1];
|
|
529
|
+
const agentIdMatch = result.match(/agent_id:\s*(\S+)@/);
|
|
530
|
+
if (agentIdMatch && !info.agentName)
|
|
531
|
+
info.agentName = agentIdMatch[1];
|
|
532
|
+
// Mark as dispatched — this enables mailbox watching and prevents idle timeout
|
|
533
|
+
info.status = 'dispatched';
|
|
534
|
+
info.dispatchedAt = Date.now();
|
|
535
|
+
const label = info.label || info.toolName;
|
|
536
|
+
const text = `🤖 ${escapeHtml(label)} — Spawned, waiting for results…`;
|
|
537
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
538
|
+
try {
|
|
539
|
+
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
540
|
+
}
|
|
541
|
+
catch { /* ignore */ }
|
|
542
|
+
});
|
|
543
|
+
await this.sendQueue;
|
|
544
|
+
return;
|
|
397
545
|
}
|
|
546
|
+
// Skip if already completed (e.g. via mailbox)
|
|
547
|
+
if (info.status === 'completed')
|
|
548
|
+
return;
|
|
549
|
+
info.status = 'completed';
|
|
550
|
+
const label = info.label || info.toolName;
|
|
551
|
+
// Truncate result for blockquote (TG message limit ~4096 chars)
|
|
552
|
+
const maxResultLen = 3500;
|
|
553
|
+
const resultText = result.length > maxResultLen ? result.slice(0, maxResultLen) + '…' : result;
|
|
554
|
+
// Use expandable blockquote — collapsed shows "✅ label" + first line, tap to expand
|
|
555
|
+
const text = `<blockquote expandable>✅ ${escapeHtml(label)}\n${escapeHtml(resultText)}</blockquote>`;
|
|
556
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
557
|
+
try {
|
|
558
|
+
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
// Ignore edit failures
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
await this.sendQueue;
|
|
398
565
|
}
|
|
399
566
|
async onBlockStart(event) {
|
|
400
567
|
if (event.content_block.type !== 'tool_use')
|
|
@@ -408,24 +575,24 @@ export class SubAgentTracker {
|
|
|
408
575
|
blockIndex: event.index,
|
|
409
576
|
tgMessageId: null,
|
|
410
577
|
status: 'running',
|
|
578
|
+
label: '',
|
|
579
|
+
agentName: '',
|
|
411
580
|
inputPreview: '',
|
|
581
|
+
dispatchedAt: null,
|
|
412
582
|
};
|
|
413
583
|
this.agents.set(block.id, info);
|
|
414
584
|
this.blockToAgent.set(event.index, block.id);
|
|
415
|
-
// Send
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
});
|
|
427
|
-
await this.sendQueue;
|
|
428
|
-
}
|
|
585
|
+
// Send standalone message (no reply_to — cleaner in private chat)
|
|
586
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
587
|
+
try {
|
|
588
|
+
const msgId = await this.sender.sendMessage(this.chatId, '🤖 Starting sub-agent…', 'HTML');
|
|
589
|
+
info.tgMessageId = msgId;
|
|
590
|
+
}
|
|
591
|
+
catch (err) {
|
|
592
|
+
// Silently ignore — main stream continues regardless
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
await this.sendQueue;
|
|
429
596
|
}
|
|
430
597
|
async onInputDelta(event) {
|
|
431
598
|
const toolUseId = this.blockToAgent.get(event.index);
|
|
@@ -435,21 +602,39 @@ export class SubAgentTracker {
|
|
|
435
602
|
if (!info || !info.tgMessageId)
|
|
436
603
|
return;
|
|
437
604
|
info.inputPreview += event.delta.partial_json;
|
|
438
|
-
//
|
|
439
|
-
if (info.
|
|
440
|
-
return;
|
|
441
|
-
const preview = info.inputPreview.length > 300
|
|
442
|
-
? info.inputPreview.slice(0, 300) + '…'
|
|
443
|
-
: info.inputPreview;
|
|
444
|
-
this.sendQueue = this.sendQueue.then(async () => {
|
|
605
|
+
// Extract agent name from input JSON (used for mailbox matching)
|
|
606
|
+
if (!info.agentName) {
|
|
445
607
|
try {
|
|
446
|
-
|
|
608
|
+
const parsed = JSON.parse(info.inputPreview);
|
|
609
|
+
if (typeof parsed.name === 'string' && parsed.name.trim()) {
|
|
610
|
+
info.agentName = parsed.name.trim();
|
|
611
|
+
}
|
|
447
612
|
}
|
|
448
613
|
catch {
|
|
449
|
-
//
|
|
614
|
+
// Partial JSON — try extracting name field
|
|
615
|
+
const nameMatch = info.inputPreview.match(/"name"\s*:\s*"([^"]+)"/);
|
|
616
|
+
if (nameMatch)
|
|
617
|
+
info.agentName = nameMatch[1];
|
|
450
618
|
}
|
|
451
|
-
}
|
|
452
|
-
|
|
619
|
+
}
|
|
620
|
+
// Try to extract an agent label
|
|
621
|
+
if (!info.label) {
|
|
622
|
+
const label = extractAgentLabel(info.inputPreview);
|
|
623
|
+
if (label) {
|
|
624
|
+
info.label = label;
|
|
625
|
+
// Once we have a label, update the message to show it
|
|
626
|
+
const displayLabel = info.label;
|
|
627
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
628
|
+
try {
|
|
629
|
+
await this.sender.editMessage(this.chatId, info.tgMessageId, `🤖 ${escapeHtml(displayLabel)} — Working…`, 'HTML');
|
|
630
|
+
}
|
|
631
|
+
catch {
|
|
632
|
+
// Ignore edit failures
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
await this.sendQueue;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
453
638
|
}
|
|
454
639
|
async onBlockStop(event) {
|
|
455
640
|
const toolUseId = this.blockToAgent.get(event.index);
|
|
@@ -458,25 +643,17 @@ export class SubAgentTracker {
|
|
|
458
643
|
const info = this.agents.get(toolUseId);
|
|
459
644
|
if (!info || !info.tgMessageId)
|
|
460
645
|
return;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
summary = summary.slice(0, 100) + '…';
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
catch {
|
|
473
|
-
summary = info.inputPreview.slice(0, 100);
|
|
474
|
-
if (info.inputPreview.length > 100)
|
|
475
|
-
summary += '…';
|
|
646
|
+
// content_block_stop = input done, NOT sub-agent done. Mark as dispatched.
|
|
647
|
+
info.status = 'dispatched';
|
|
648
|
+
info.dispatchedAt = Date.now();
|
|
649
|
+
// Final chance to extract label from complete input
|
|
650
|
+
if (!info.label) {
|
|
651
|
+
const label = extractAgentLabel(info.inputPreview);
|
|
652
|
+
if (label)
|
|
653
|
+
info.label = label;
|
|
476
654
|
}
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
: `✅ Sub-agent completed: <code>${escapeHtml(info.toolName)}</code>`;
|
|
655
|
+
const displayLabel = info.label || info.toolName;
|
|
656
|
+
const text = `🤖 ${escapeHtml(displayLabel)} — Working…`;
|
|
480
657
|
this.sendQueue = this.sendQueue.then(async () => {
|
|
481
658
|
try {
|
|
482
659
|
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
@@ -486,11 +663,137 @@ export class SubAgentTracker {
|
|
|
486
663
|
}
|
|
487
664
|
});
|
|
488
665
|
await this.sendQueue;
|
|
666
|
+
// Start elapsed timer — update every 15s to show progress
|
|
667
|
+
}
|
|
668
|
+
/** Start a periodic timer that edits the message with elapsed time */
|
|
669
|
+
/** Set callback invoked when ALL dispatched sub-agents have mailbox results. */
|
|
670
|
+
setOnAllReported(cb) {
|
|
671
|
+
this.onAllReported = cb;
|
|
672
|
+
}
|
|
673
|
+
/** Set the CC team name (extracted from spawn confirmation tool_result). */
|
|
674
|
+
setTeamName(name) {
|
|
675
|
+
this.teamName = name;
|
|
676
|
+
this.mailboxPath = join(homedir(), '.claude', 'teams', name, 'inboxes', 'team-lead.json');
|
|
677
|
+
}
|
|
678
|
+
get currentTeamName() { return this.teamName; }
|
|
679
|
+
get isMailboxWatching() { return this.mailboxWatching; }
|
|
680
|
+
/** Start watching the mailbox file for sub-agent results. */
|
|
681
|
+
startMailboxWatch() {
|
|
682
|
+
if (this.mailboxWatching) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
if (!this.mailboxPath) {
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
this.mailboxWatching = true;
|
|
689
|
+
// Ensure directory exists so watchFile doesn't error
|
|
690
|
+
const dir = dirname(this.mailboxPath);
|
|
691
|
+
if (!existsSync(dir)) {
|
|
692
|
+
mkdirSync(dir, { recursive: true });
|
|
693
|
+
}
|
|
694
|
+
// Start from 0 — process all messages including pre-existing ones
|
|
695
|
+
// Background agents may finish before the watcher starts
|
|
696
|
+
this.lastMailboxCount = 0;
|
|
697
|
+
// Process immediately in case messages arrived before watching
|
|
698
|
+
this.processMailbox();
|
|
699
|
+
watchFile(this.mailboxPath, { interval: 2000 }, () => {
|
|
700
|
+
this.processMailbox();
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
/** Stop watching the mailbox file. */
|
|
704
|
+
stopMailboxWatch() {
|
|
705
|
+
if (!this.mailboxWatching || !this.mailboxPath)
|
|
706
|
+
return;
|
|
707
|
+
try {
|
|
708
|
+
unwatchFile(this.mailboxPath);
|
|
709
|
+
}
|
|
710
|
+
catch { /* ignore */ }
|
|
711
|
+
this.mailboxWatching = false;
|
|
712
|
+
}
|
|
713
|
+
/** Read and parse the mailbox file. Returns [] on any error. */
|
|
714
|
+
readMailboxMessages() {
|
|
715
|
+
if (!this.mailboxPath || !existsSync(this.mailboxPath))
|
|
716
|
+
return [];
|
|
717
|
+
try {
|
|
718
|
+
const raw = readFileSync(this.mailboxPath, 'utf-8');
|
|
719
|
+
const parsed = JSON.parse(raw);
|
|
720
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
return [];
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
/** Process new mailbox messages and update sub-agent TG messages. */
|
|
727
|
+
processMailbox() {
|
|
728
|
+
const messages = this.readMailboxMessages();
|
|
729
|
+
if (messages.length <= this.lastMailboxCount)
|
|
730
|
+
return;
|
|
731
|
+
const newMessages = messages.slice(this.lastMailboxCount);
|
|
732
|
+
this.lastMailboxCount = messages.length;
|
|
733
|
+
for (const msg of newMessages) {
|
|
734
|
+
// Don't filter by msg.read — CC may read its mailbox before our 2s poll fires
|
|
735
|
+
// We track by message count (lastMailboxCount) to avoid duplicates
|
|
736
|
+
// Skip idle notifications (JSON objects, not real results)
|
|
737
|
+
if (msg.text.startsWith('{'))
|
|
738
|
+
continue;
|
|
739
|
+
// Match msg.from to a tracked sub-agent
|
|
740
|
+
const matched = this.findAgentByFrom(msg.from);
|
|
741
|
+
if (!matched) {
|
|
742
|
+
console.error(`[MAILBOX] No match for from="${msg.from}". Agents: ${[...this.agents.values()].map(a => `${a.agentName}/${a.label}/${a.status}`).join(', ')}`);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
matched.status = 'completed';
|
|
746
|
+
if (!matched.tgMessageId)
|
|
747
|
+
continue;
|
|
748
|
+
// React with ✅ instead of editing — avoids race conditions
|
|
749
|
+
const msgId = matched.tgMessageId;
|
|
750
|
+
const emoji = msg.color === 'red' ? '👎' : '👍';
|
|
751
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
752
|
+
try {
|
|
753
|
+
await this.sender.setReaction?.(this.chatId, msgId, emoji);
|
|
754
|
+
}
|
|
755
|
+
catch { /* ignore — reaction might not be supported */ }
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
// Check if ALL dispatched agents are now completed
|
|
759
|
+
if (this.onAllReported && !this.hasDispatchedAgents && this.agents.size > 0) {
|
|
760
|
+
// All done — invoke callback
|
|
761
|
+
const cb = this.onAllReported;
|
|
762
|
+
// Defer slightly to let edits flush
|
|
763
|
+
setTimeout(() => cb(), 500);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** Find a tracked sub-agent whose label matches the mailbox message's `from` field. */
|
|
767
|
+
findAgentByFrom(from) {
|
|
768
|
+
const fromLower = from.toLowerCase();
|
|
769
|
+
for (const info of this.agents.values()) {
|
|
770
|
+
if (info.status !== 'dispatched')
|
|
771
|
+
continue;
|
|
772
|
+
// Primary match: agentName (CC's internal agent name, used as mailbox 'from')
|
|
773
|
+
if (info.agentName && info.agentName.toLowerCase() === fromLower) {
|
|
774
|
+
return info;
|
|
775
|
+
}
|
|
776
|
+
// Fallback: fuzzy label match
|
|
777
|
+
const label = (info.label || info.toolName).toLowerCase();
|
|
778
|
+
if (label === fromLower || label.includes(fromLower) || fromLower.includes(label)) {
|
|
779
|
+
return info;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return null;
|
|
489
783
|
}
|
|
490
784
|
reset() {
|
|
785
|
+
// Stop mailbox watching
|
|
786
|
+
this.stopMailboxWatch();
|
|
787
|
+
// Clear all elapsed timers before resetting
|
|
788
|
+
for (const info of this.agents.values()) {
|
|
789
|
+
}
|
|
491
790
|
this.agents.clear();
|
|
492
791
|
this.blockToAgent.clear();
|
|
493
792
|
this.sendQueue = Promise.resolve();
|
|
793
|
+
this.teamName = null;
|
|
794
|
+
this.mailboxPath = null;
|
|
795
|
+
this.lastMailboxCount = 0;
|
|
796
|
+
this.onAllReported = null;
|
|
494
797
|
}
|
|
495
798
|
}
|
|
496
799
|
// ── Utility: split a completed text into TG-sized chunks ──
|