@fonz/tgcc 0.0.1 → 0.3.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/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/bridge.d.ts +32 -0
- package/dist/bridge.js +1193 -0
- package/dist/bridge.js.map +1 -0
- package/dist/cc-process.d.ts +75 -0
- package/dist/cc-process.js +426 -0
- package/dist/cc-process.js.map +1 -0
- package/dist/cc-protocol.d.ts +255 -0
- package/dist/cc-protocol.js +109 -0
- package/dist/cc-protocol.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +668 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +75 -0
- package/dist/config.js +268 -0
- package/dist/config.js.map +1 -0
- package/dist/ctl-server.d.ts +57 -0
- package/dist/ctl-server.js +98 -0
- package/dist/ctl-server.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-bridge.d.ts +45 -0
- package/dist/mcp-bridge.js +182 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +109 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/service.d.ts +1 -0
- package/dist/service.js +84 -0
- package/dist/service.js.map +1 -0
- package/dist/session.d.ts +71 -0
- package/dist/session.js +438 -0
- package/dist/session.js.map +1 -0
- package/dist/streaming.d.ts +178 -0
- package/dist/streaming.js +814 -0
- package/dist/streaming.js.map +1 -0
- 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 +71 -0
- package/dist/telegram.js +384 -0
- package/dist/telegram.js.map +1 -0
- package/package.json +95 -4
|
@@ -0,0 +1,814 @@
|
|
|
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';
|
|
5
|
+
// ── HTML safety & conversion ──
|
|
6
|
+
/** Escape characters that are special in Telegram HTML. */
|
|
7
|
+
export function escapeHtml(text) {
|
|
8
|
+
return text
|
|
9
|
+
.replace(/&/g, '&')
|
|
10
|
+
.replace(/</g, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Convert markdown text to Telegram-safe HTML using the marked library.
|
|
15
|
+
* Replaces the old hand-rolled implementation with a proper markdown parser.
|
|
16
|
+
*/
|
|
17
|
+
export function markdownToHtml(text) {
|
|
18
|
+
return markdownToTelegramHtml(text);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Make text safe for Telegram HTML parse mode during streaming.
|
|
22
|
+
* Closes unclosed HTML tags from partial markdown conversion.
|
|
23
|
+
*/
|
|
24
|
+
export function makeHtmlSafe(text) {
|
|
25
|
+
return markdownToHtml(text);
|
|
26
|
+
}
|
|
27
|
+
/** @deprecated Use makeHtmlSafe instead */
|
|
28
|
+
export function makeMarkdownSafe(text) {
|
|
29
|
+
return makeHtmlSafe(text);
|
|
30
|
+
}
|
|
31
|
+
// ── Stream Accumulator ──
|
|
32
|
+
export class StreamAccumulator {
|
|
33
|
+
chatId;
|
|
34
|
+
sender;
|
|
35
|
+
editIntervalMs;
|
|
36
|
+
splitThreshold;
|
|
37
|
+
// State
|
|
38
|
+
tgMessageId = null;
|
|
39
|
+
buffer = '';
|
|
40
|
+
thinkingBuffer = '';
|
|
41
|
+
imageBase64Buffer = '';
|
|
42
|
+
currentBlockType = null;
|
|
43
|
+
lastEditTime = 0;
|
|
44
|
+
editTimer = null;
|
|
45
|
+
thinkingIndicatorShown = false;
|
|
46
|
+
toolIndicators = [];
|
|
47
|
+
messageIds = []; // all message IDs sent during this turn
|
|
48
|
+
finished = false;
|
|
49
|
+
sendQueue = Promise.resolve();
|
|
50
|
+
turnUsage = null;
|
|
51
|
+
constructor(options) {
|
|
52
|
+
this.chatId = options.chatId;
|
|
53
|
+
this.sender = options.sender;
|
|
54
|
+
this.editIntervalMs = options.editIntervalMs ?? 1000;
|
|
55
|
+
this.splitThreshold = options.splitThreshold ?? 4000;
|
|
56
|
+
}
|
|
57
|
+
get allMessageIds() { return [...this.messageIds]; }
|
|
58
|
+
/** Set usage stats for the current turn (called from bridge on result event) */
|
|
59
|
+
setTurnUsage(usage) {
|
|
60
|
+
this.turnUsage = usage;
|
|
61
|
+
}
|
|
62
|
+
// ── Process stream events ──
|
|
63
|
+
async handleEvent(event) {
|
|
64
|
+
switch (event.type) {
|
|
65
|
+
case 'message_start':
|
|
66
|
+
// Bridge handles reset decision - no automatic reset here
|
|
67
|
+
break;
|
|
68
|
+
case 'content_block_start':
|
|
69
|
+
await this.onContentBlockStart(event);
|
|
70
|
+
break;
|
|
71
|
+
case 'content_block_delta':
|
|
72
|
+
await this.onContentBlockDelta(event);
|
|
73
|
+
break;
|
|
74
|
+
case 'content_block_stop':
|
|
75
|
+
if (this.currentBlockType === 'thinking' && this.thinkingBuffer) {
|
|
76
|
+
// Thinking block complete — store for later rendering with text
|
|
77
|
+
// Will be prepended as expandable blockquote when text starts or on finalize
|
|
78
|
+
}
|
|
79
|
+
else if (this.currentBlockType === 'image' && this.imageBase64Buffer) {
|
|
80
|
+
await this.sendImage();
|
|
81
|
+
}
|
|
82
|
+
this.currentBlockType = null;
|
|
83
|
+
break;
|
|
84
|
+
case 'message_stop':
|
|
85
|
+
await this.finalize();
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async onContentBlockStart(event) {
|
|
90
|
+
const blockType = event.content_block.type;
|
|
91
|
+
if (blockType === 'thinking') {
|
|
92
|
+
this.currentBlockType = 'thinking';
|
|
93
|
+
if (!this.thinkingIndicatorShown && !this.buffer) {
|
|
94
|
+
await this.sendOrEdit('<i>💭 Thinking...</i>', true);
|
|
95
|
+
this.thinkingIndicatorShown = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (blockType === 'text') {
|
|
99
|
+
this.currentBlockType = 'text';
|
|
100
|
+
// Clear tool indicators when real text starts
|
|
101
|
+
this.toolIndicators = [];
|
|
102
|
+
}
|
|
103
|
+
else if (blockType === 'tool_use') {
|
|
104
|
+
this.currentBlockType = 'tool_use';
|
|
105
|
+
const name = event.content_block.name;
|
|
106
|
+
this.toolIndicators.push(name);
|
|
107
|
+
await this.showToolIndicator(name);
|
|
108
|
+
}
|
|
109
|
+
else if (blockType === 'image') {
|
|
110
|
+
this.currentBlockType = 'image';
|
|
111
|
+
this.imageBase64Buffer = '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async onContentBlockDelta(event) {
|
|
115
|
+
if (this.currentBlockType === 'text' && 'delta' in event) {
|
|
116
|
+
const delta = event.delta;
|
|
117
|
+
if (delta?.type === 'text_delta') {
|
|
118
|
+
this.buffer += delta.text;
|
|
119
|
+
await this.throttledEdit();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
else if (this.currentBlockType === 'thinking' && 'delta' in event) {
|
|
123
|
+
const delta = event.delta;
|
|
124
|
+
if (delta?.type === 'thinking_delta' && delta.thinking) {
|
|
125
|
+
this.thinkingBuffer += delta.thinking;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
else if (this.currentBlockType === 'image' && 'delta' in event) {
|
|
129
|
+
const delta = event.delta;
|
|
130
|
+
if (delta?.type === 'image_delta' && delta.data) {
|
|
131
|
+
this.imageBase64Buffer += delta.data;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Ignore input_json_delta content
|
|
135
|
+
}
|
|
136
|
+
// ── TG message management ──
|
|
137
|
+
/** Send or edit a message. If rawHtml is true, text is already HTML-safe. */
|
|
138
|
+
async sendOrEdit(text, rawHtml = false) {
|
|
139
|
+
this.sendQueue = this.sendQueue.then(() => this._doSendOrEdit(text, rawHtml));
|
|
140
|
+
return this.sendQueue;
|
|
141
|
+
}
|
|
142
|
+
async _doSendOrEdit(text, rawHtml = false) {
|
|
143
|
+
const safeText = (rawHtml ? text : makeHtmlSafe(text)) || '...';
|
|
144
|
+
// Update timing BEFORE API call to prevent races
|
|
145
|
+
this.lastEditTime = Date.now();
|
|
146
|
+
try {
|
|
147
|
+
if (!this.tgMessageId) {
|
|
148
|
+
this.tgMessageId = await this.sender.sendMessage(this.chatId, safeText, 'HTML');
|
|
149
|
+
this.messageIds.push(this.tgMessageId);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
await this.sender.editMessage(this.chatId, this.tgMessageId, safeText, 'HTML');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
// Handle TG rate limit (429)
|
|
157
|
+
if (err && typeof err === 'object' && 'error_code' in err && err.error_code === 429) {
|
|
158
|
+
const retryAfter = err.parameters?.retry_after ?? 5;
|
|
159
|
+
this.editIntervalMs = Math.min(this.editIntervalMs * 2, 5000);
|
|
160
|
+
await sleep(retryAfter * 1000);
|
|
161
|
+
return this._doSendOrEdit(text);
|
|
162
|
+
}
|
|
163
|
+
// Ignore "message is not modified" errors
|
|
164
|
+
if (err instanceof Error && err.message.includes('message is not modified'))
|
|
165
|
+
return;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async sendImage() {
|
|
170
|
+
if (!this.sender.sendPhoto || !this.imageBase64Buffer)
|
|
171
|
+
return;
|
|
172
|
+
try {
|
|
173
|
+
const imageBuffer = Buffer.from(this.imageBase64Buffer, 'base64');
|
|
174
|
+
const msgId = await this.sender.sendPhoto(this.chatId, imageBuffer);
|
|
175
|
+
this.messageIds.push(msgId);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
// Fall back to text indicator on failure
|
|
179
|
+
this.buffer += '\n[Image could not be sent]';
|
|
180
|
+
}
|
|
181
|
+
this.imageBase64Buffer = '';
|
|
182
|
+
}
|
|
183
|
+
async showToolIndicator(toolName) {
|
|
184
|
+
const bufferHtml = this.buffer ? makeHtmlSafe(this.buffer) : '';
|
|
185
|
+
const indicator = bufferHtml
|
|
186
|
+
? `${bufferHtml}\n\n<i>Using ${escapeHtml(toolName)}...</i>`
|
|
187
|
+
: `<i>Using ${escapeHtml(toolName)}...</i>`;
|
|
188
|
+
await this.sendOrEdit(indicator, true);
|
|
189
|
+
}
|
|
190
|
+
async throttledEdit() {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const elapsed = now - this.lastEditTime;
|
|
193
|
+
if (elapsed >= this.editIntervalMs) {
|
|
194
|
+
// Enough time passed — edit now
|
|
195
|
+
await this.doEdit();
|
|
196
|
+
}
|
|
197
|
+
else if (!this.editTimer) {
|
|
198
|
+
// Schedule an edit
|
|
199
|
+
const delay = this.editIntervalMs - elapsed;
|
|
200
|
+
this.editTimer = setTimeout(async () => {
|
|
201
|
+
this.editTimer = null;
|
|
202
|
+
if (!this.finished) {
|
|
203
|
+
await this.doEdit();
|
|
204
|
+
}
|
|
205
|
+
}, delay);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
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
|
+
*/
|
|
212
|
+
buildFullText(includeSuffix = false) {
|
|
213
|
+
let text = '';
|
|
214
|
+
if (this.thinkingBuffer) {
|
|
215
|
+
const thinkingPreview = this.thinkingBuffer.length > 1024
|
|
216
|
+
? this.thinkingBuffer.slice(0, 1024) + '…'
|
|
217
|
+
: this.thinkingBuffer;
|
|
218
|
+
text += `<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>\n`;
|
|
219
|
+
}
|
|
220
|
+
// Convert markdown buffer to HTML-safe text
|
|
221
|
+
text += makeHtmlSafe(this.buffer);
|
|
222
|
+
if (includeSuffix && this.turnUsage) {
|
|
223
|
+
text += '\n' + formatUsageFooter(this.turnUsage);
|
|
224
|
+
}
|
|
225
|
+
return { text, hasHtmlSuffix: includeSuffix && !!this.turnUsage };
|
|
226
|
+
}
|
|
227
|
+
async doEdit() {
|
|
228
|
+
if (!this.buffer)
|
|
229
|
+
return;
|
|
230
|
+
const { text, hasHtmlSuffix } = this.buildFullText();
|
|
231
|
+
// Check if we need to split
|
|
232
|
+
if (text.length > this.splitThreshold) {
|
|
233
|
+
await this.splitMessage();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
|
|
237
|
+
}
|
|
238
|
+
async splitMessage() {
|
|
239
|
+
// Find a good split point near the threshold
|
|
240
|
+
const splitAt = findSplitPoint(this.buffer, this.splitThreshold);
|
|
241
|
+
const firstPart = this.buffer.slice(0, splitAt);
|
|
242
|
+
const remainder = this.buffer.slice(splitAt);
|
|
243
|
+
// Finalize current message with first part
|
|
244
|
+
await this.sendOrEdit(makeHtmlSafe(firstPart), true);
|
|
245
|
+
// Start a new message for remainder
|
|
246
|
+
this.tgMessageId = null;
|
|
247
|
+
this.buffer = remainder;
|
|
248
|
+
if (this.buffer) {
|
|
249
|
+
await this.sendOrEdit(makeHtmlSafe(this.buffer), true);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async finalize() {
|
|
253
|
+
this.finished = true;
|
|
254
|
+
// Clear any pending edit timer
|
|
255
|
+
if (this.editTimer) {
|
|
256
|
+
clearTimeout(this.editTimer);
|
|
257
|
+
this.editTimer = null;
|
|
258
|
+
}
|
|
259
|
+
if (this.buffer) {
|
|
260
|
+
// Final edit with complete text including thinking blockquote and usage footer
|
|
261
|
+
const { text } = this.buildFullText(true);
|
|
262
|
+
await this.sendOrEdit(text, true); // buildFullText already does makeHtmlSafe
|
|
263
|
+
}
|
|
264
|
+
else if (this.thinkingBuffer && this.thinkingIndicatorShown) {
|
|
265
|
+
// Only thinking happened, no text — show thinking as expandable blockquote
|
|
266
|
+
const thinkingPreview = this.thinkingBuffer.length > 1024
|
|
267
|
+
? this.thinkingBuffer.slice(0, 1024) + '…'
|
|
268
|
+
: this.thinkingBuffer;
|
|
269
|
+
await this.sendOrEdit(`<blockquote expandable>💭 Thinking\n${escapeHtml(thinkingPreview)}</blockquote>`, true);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
clearEditTimer() {
|
|
273
|
+
if (this.editTimer) {
|
|
274
|
+
clearTimeout(this.editTimer);
|
|
275
|
+
this.editTimer = null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Soft reset: clear buffer/state but keep tgMessageId so next turn edits the same message */
|
|
279
|
+
softReset() {
|
|
280
|
+
this.buffer = '';
|
|
281
|
+
this.thinkingBuffer = '';
|
|
282
|
+
this.imageBase64Buffer = '';
|
|
283
|
+
this.currentBlockType = null;
|
|
284
|
+
this.lastEditTime = 0;
|
|
285
|
+
this.thinkingIndicatorShown = false;
|
|
286
|
+
this.toolIndicators = [];
|
|
287
|
+
this.finished = false;
|
|
288
|
+
this.turnUsage = null;
|
|
289
|
+
this.clearEditTimer(); // Ensure cleanup
|
|
290
|
+
}
|
|
291
|
+
/** Full reset: also clears tgMessageId (next send creates a new message) */
|
|
292
|
+
reset() {
|
|
293
|
+
this.softReset();
|
|
294
|
+
this.tgMessageId = null;
|
|
295
|
+
this.messageIds = [];
|
|
296
|
+
this.sendQueue = Promise.resolve();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// ── Helpers ──
|
|
300
|
+
function findSplitPoint(text, threshold) {
|
|
301
|
+
// Try to split at paragraph break
|
|
302
|
+
const paragraphBreak = text.lastIndexOf('\n\n', threshold);
|
|
303
|
+
if (paragraphBreak > threshold * 0.5)
|
|
304
|
+
return paragraphBreak;
|
|
305
|
+
// Try to split at line break
|
|
306
|
+
const lineBreak = text.lastIndexOf('\n', threshold);
|
|
307
|
+
if (lineBreak > threshold * 0.5)
|
|
308
|
+
return lineBreak;
|
|
309
|
+
// Try to split at sentence end
|
|
310
|
+
const sentenceEnd = text.lastIndexOf('. ', threshold);
|
|
311
|
+
if (sentenceEnd > threshold * 0.5)
|
|
312
|
+
return sentenceEnd + 2;
|
|
313
|
+
// Fall back to threshold
|
|
314
|
+
return threshold;
|
|
315
|
+
}
|
|
316
|
+
function sleep(ms) {
|
|
317
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
318
|
+
}
|
|
319
|
+
/** Format token count as human-readable: 1234 → "1.2k", 500 → "500" */
|
|
320
|
+
function formatTokens(n) {
|
|
321
|
+
if (n >= 1000)
|
|
322
|
+
return (n / 1000).toFixed(1) + 'k';
|
|
323
|
+
return String(n);
|
|
324
|
+
}
|
|
325
|
+
/** Format usage stats as an HTML italic footer line */
|
|
326
|
+
export function formatUsageFooter(usage) {
|
|
327
|
+
const parts = [
|
|
328
|
+
`↩️ ${formatTokens(usage.inputTokens)} in`,
|
|
329
|
+
`${formatTokens(usage.outputTokens)} out`,
|
|
330
|
+
];
|
|
331
|
+
if (usage.costUsd != null) {
|
|
332
|
+
parts.push(`$${usage.costUsd.toFixed(4)}`);
|
|
333
|
+
}
|
|
334
|
+
return `<i>${parts.join(' · ')}</i>`;
|
|
335
|
+
}
|
|
336
|
+
// ── Sub-agent detection patterns ──
|
|
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
|
+
]);
|
|
343
|
+
export function isSubAgentTool(toolName) {
|
|
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;
|
|
430
|
+
}
|
|
431
|
+
export class SubAgentTracker {
|
|
432
|
+
chatId;
|
|
433
|
+
sender;
|
|
434
|
+
agents = new Map(); // toolUseId → info
|
|
435
|
+
blockToAgent = new Map(); // blockIndex → toolUseId
|
|
436
|
+
sendQueue = Promise.resolve();
|
|
437
|
+
teamName = null;
|
|
438
|
+
mailboxPath = null;
|
|
439
|
+
mailboxWatching = false;
|
|
440
|
+
lastMailboxCount = 0;
|
|
441
|
+
onAllReported = null;
|
|
442
|
+
hasPendingFollowUp = false;
|
|
443
|
+
constructor(options) {
|
|
444
|
+
this.chatId = options.chatId;
|
|
445
|
+
this.sender = options.sender;
|
|
446
|
+
}
|
|
447
|
+
get activeAgents() {
|
|
448
|
+
return [...this.agents.values()];
|
|
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
|
+
}
|
|
476
|
+
async handleEvent(event) {
|
|
477
|
+
switch (event.type) {
|
|
478
|
+
case 'content_block_start':
|
|
479
|
+
await this.onBlockStart(event);
|
|
480
|
+
break;
|
|
481
|
+
case 'content_block_delta': {
|
|
482
|
+
const delta = event;
|
|
483
|
+
if (delta.delta?.type === 'input_json_delta') {
|
|
484
|
+
await this.onInputDelta(delta);
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
case 'content_block_stop':
|
|
489
|
+
await this.onBlockStop(event);
|
|
490
|
+
break;
|
|
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;
|
|
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;
|
|
565
|
+
}
|
|
566
|
+
async onBlockStart(event) {
|
|
567
|
+
if (event.content_block.type !== 'tool_use')
|
|
568
|
+
return;
|
|
569
|
+
const block = event.content_block;
|
|
570
|
+
if (!isSubAgentTool(block.name))
|
|
571
|
+
return;
|
|
572
|
+
const info = {
|
|
573
|
+
toolUseId: block.id,
|
|
574
|
+
toolName: block.name,
|
|
575
|
+
blockIndex: event.index,
|
|
576
|
+
tgMessageId: null,
|
|
577
|
+
status: 'running',
|
|
578
|
+
label: '',
|
|
579
|
+
agentName: '',
|
|
580
|
+
inputPreview: '',
|
|
581
|
+
dispatchedAt: null,
|
|
582
|
+
};
|
|
583
|
+
this.agents.set(block.id, info);
|
|
584
|
+
this.blockToAgent.set(event.index, block.id);
|
|
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;
|
|
596
|
+
}
|
|
597
|
+
async onInputDelta(event) {
|
|
598
|
+
const toolUseId = this.blockToAgent.get(event.index);
|
|
599
|
+
if (!toolUseId)
|
|
600
|
+
return;
|
|
601
|
+
const info = this.agents.get(toolUseId);
|
|
602
|
+
if (!info || !info.tgMessageId)
|
|
603
|
+
return;
|
|
604
|
+
info.inputPreview += event.delta.partial_json;
|
|
605
|
+
// Extract agent name from input JSON (used for mailbox matching)
|
|
606
|
+
if (!info.agentName) {
|
|
607
|
+
try {
|
|
608
|
+
const parsed = JSON.parse(info.inputPreview);
|
|
609
|
+
if (typeof parsed.name === 'string' && parsed.name.trim()) {
|
|
610
|
+
info.agentName = parsed.name.trim();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
// Partial JSON — try extracting name field
|
|
615
|
+
const nameMatch = info.inputPreview.match(/"name"\s*:\s*"([^"]+)"/);
|
|
616
|
+
if (nameMatch)
|
|
617
|
+
info.agentName = nameMatch[1];
|
|
618
|
+
}
|
|
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
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async onBlockStop(event) {
|
|
640
|
+
const toolUseId = this.blockToAgent.get(event.index);
|
|
641
|
+
if (!toolUseId)
|
|
642
|
+
return;
|
|
643
|
+
const info = this.agents.get(toolUseId);
|
|
644
|
+
if (!info || !info.tgMessageId)
|
|
645
|
+
return;
|
|
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;
|
|
654
|
+
}
|
|
655
|
+
const displayLabel = info.label || info.toolName;
|
|
656
|
+
const text = `🤖 ${escapeHtml(displayLabel)} — Working…`;
|
|
657
|
+
this.sendQueue = this.sendQueue.then(async () => {
|
|
658
|
+
try {
|
|
659
|
+
await this.sender.editMessage(this.chatId, info.tgMessageId, text, 'HTML');
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// Ignore edit failures
|
|
663
|
+
}
|
|
664
|
+
});
|
|
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;
|
|
783
|
+
}
|
|
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
|
+
}
|
|
790
|
+
this.agents.clear();
|
|
791
|
+
this.blockToAgent.clear();
|
|
792
|
+
this.sendQueue = Promise.resolve();
|
|
793
|
+
this.teamName = null;
|
|
794
|
+
this.mailboxPath = null;
|
|
795
|
+
this.lastMailboxCount = 0;
|
|
796
|
+
this.onAllReported = null;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// ── Utility: split a completed text into TG-sized chunks ──
|
|
800
|
+
export function splitText(text, maxLength = 4000) {
|
|
801
|
+
if (text.length <= maxLength)
|
|
802
|
+
return [text];
|
|
803
|
+
const chunks = [];
|
|
804
|
+
let remaining = text;
|
|
805
|
+
while (remaining.length > maxLength) {
|
|
806
|
+
const splitAt = findSplitPoint(remaining, maxLength);
|
|
807
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
808
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
809
|
+
}
|
|
810
|
+
if (remaining)
|
|
811
|
+
chunks.push(remaining);
|
|
812
|
+
return chunks;
|
|
813
|
+
}
|
|
814
|
+
//# sourceMappingURL=streaming.js.map
|