@gonzih/cc-tg 0.9.30 → 0.9.31

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/bot.js ADDED
@@ -0,0 +1,1525 @@
1
+ /**
2
+ * Telegram bot that routes messages to/from a Claude Code subprocess.
3
+ * One ClaudeProcess per chat_id — sessions are isolated per user.
4
+ */
5
+ import TelegramBot from "node-telegram-bot-api";
6
+ import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { resolve, basename, join } from "path";
8
+ import os from "os";
9
+ import { execSync, spawn } from "child_process";
10
+ import https from "https";
11
+ import http from "http";
12
+ import { ClaudeProcess, extractText } from "./claude.js";
13
+ import { transcribeVoice, isVoiceAvailable } from "./voice.js";
14
+ import { formatForTelegram, splitLongMessage } from "./formatter.js";
15
+ import { detectUsageLimit } from "./usage-limit.js";
16
+ import { getCurrentToken, rotateToken, getTokenIndex, getTokenCount } from "./tokens.js";
17
+ import { writeChatLog } from "./notifier.js";
18
+ import { CronManager } from "./cron.js";
19
+ const BOT_COMMANDS = [
20
+ { command: "start", description: "Reset session and start fresh" },
21
+ { command: "reset", description: "Reset Claude session" },
22
+ { command: "stop", description: "Stop the current Claude task" },
23
+ { command: "status", description: "Check if a session is active" },
24
+ { command: "help", description: "Show all available commands" },
25
+ { command: "reload_mcp", description: "Restart the cc-agent MCP server process" },
26
+ { command: "mcp_status", description: "Check MCP server connection status" },
27
+ { command: "mcp_version", description: "Show cc-agent npm version and npx cache info" },
28
+ { command: "clear_npx_cache", description: "Clear npx cache and restart MCP to pick up latest version" },
29
+ { command: "restart", description: "Restart the bot process in-place" },
30
+ { command: "get_file", description: "Send a file from the server to this chat" },
31
+ { command: "cost", description: "Show session token usage and cost" },
32
+ { command: "skills", description: "List available Claude skills with descriptions" },
33
+ { command: "cron", description: "Manage cron jobs — add/list/edit/remove/clear" },
34
+ { command: "voice_retry", description: "Retry failed voice message transcriptions" },
35
+ { command: "drivers", description: "List available agent drivers" },
36
+ { command: "agents", description: "Show running meta-agents and their live status" },
37
+ ];
38
+ const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
39
+ const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
40
+ // Claude Sonnet 4.6 pricing (per 1M tokens)
41
+ const PRICING = {
42
+ inputPerM: 3.00,
43
+ outputPerM: 15.00,
44
+ cacheReadPerM: 0.30,
45
+ cacheWritePerM: 3.75,
46
+ };
47
+ function computeCostUsd(usage) {
48
+ return (usage.inputTokens * PRICING.inputPerM / 1_000_000 +
49
+ usage.outputTokens * PRICING.outputPerM / 1_000_000 +
50
+ usage.cacheReadTokens * PRICING.cacheReadPerM / 1_000_000 +
51
+ usage.cacheWriteTokens * PRICING.cacheWritePerM / 1_000_000);
52
+ }
53
+ /** Prepend [MM-DD HH:mm] so Claude knows when the message was received. Not shown in Telegram. */
54
+ export function stampPrompt(text, now = new Date()) {
55
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
56
+ const dd = String(now.getDate()).padStart(2, "0");
57
+ const hh = String(now.getHours()).padStart(2, "0");
58
+ const min = String(now.getMinutes()).padStart(2, "0");
59
+ return `[${mm}-${dd} ${hh}:${min}] ${text}`;
60
+ }
61
+ function formatTokens(n) {
62
+ if (n >= 1000)
63
+ return `${(n / 1000).toFixed(1)}k`;
64
+ return String(n);
65
+ }
66
+ function formatCostReport(cost) {
67
+ const inputCost = cost.totalInputTokens * PRICING.inputPerM / 1_000_000;
68
+ const outputCost = cost.totalOutputTokens * PRICING.outputPerM / 1_000_000;
69
+ const cacheReadCost = cost.totalCacheReadTokens * PRICING.cacheReadPerM / 1_000_000;
70
+ const cacheWriteCost = cost.totalCacheWriteTokens * PRICING.cacheWritePerM / 1_000_000;
71
+ return [
72
+ "📊 Session cost",
73
+ `Messages: ${cost.messageCount}`,
74
+ `Total: $${cost.totalCostUsd.toFixed(3)}`,
75
+ ` Input: ${formatTokens(cost.totalInputTokens)} tokens ($${inputCost.toFixed(3)})`,
76
+ ` Output: ${formatTokens(cost.totalOutputTokens)} tokens ($${outputCost.toFixed(3)})`,
77
+ ` Cache read: ${formatTokens(cost.totalCacheReadTokens)} tokens ($${cacheReadCost.toFixed(3)})`,
78
+ ` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
79
+ ].join("\n");
80
+ }
81
+ function formatAgentCostSummary(text) {
82
+ try {
83
+ const data = JSON.parse(text);
84
+ const totalCost = (data.total_cost_usd ?? data.total_cost ?? 0);
85
+ const byRepo = (data.by_repo ?? []);
86
+ if (byRepo.length === 0) {
87
+ return "No cost data available yet.";
88
+ }
89
+ const lines = ["💰 Cost Summary", ""];
90
+ // Align repo names with right-padded costs
91
+ const maxLen = Math.max(...byRepo.map((e) => (e.repo ?? e.repository ?? "unknown").length));
92
+ for (const entry of byRepo) {
93
+ const repo = (entry.repo ?? entry.repository ?? "unknown");
94
+ const cost = (entry.cost_usd ?? entry.cost ?? 0);
95
+ const pad = " ".repeat(maxLen - repo.length + 3);
96
+ lines.push(`${repo}${pad}$${cost.toFixed(2)}`);
97
+ }
98
+ lines.push("");
99
+ lines.push(`Total: $${totalCost.toFixed(2)}`);
100
+ return lines.join("\n");
101
+ }
102
+ catch {
103
+ return `💰 Cost Summary\n${text}`;
104
+ }
105
+ }
106
+ class CostStore {
107
+ costs = new Map();
108
+ storePath;
109
+ constructor(cwd) {
110
+ this.storePath = join(cwd, ".cc-tg", "costs.json");
111
+ this.load();
112
+ }
113
+ get(chatId) {
114
+ let cost = this.costs.get(chatId);
115
+ if (!cost) {
116
+ cost = { totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, totalCostUsd: 0, messageCount: 0 };
117
+ this.costs.set(chatId, cost);
118
+ }
119
+ return cost;
120
+ }
121
+ addUsage(chatId, usage) {
122
+ const cost = this.get(chatId);
123
+ cost.totalInputTokens += usage.inputTokens;
124
+ cost.totalOutputTokens += usage.outputTokens;
125
+ cost.totalCacheReadTokens += usage.cacheReadTokens;
126
+ cost.totalCacheWriteTokens += usage.cacheWriteTokens;
127
+ cost.totalCostUsd += computeCostUsd(usage);
128
+ this.persist();
129
+ }
130
+ incrementMessages(chatId) {
131
+ const cost = this.get(chatId);
132
+ cost.messageCount++;
133
+ this.persist();
134
+ }
135
+ persist() {
136
+ try {
137
+ const dir = join(this.storePath, "..");
138
+ if (!existsSync(dir))
139
+ mkdirSync(dir, { recursive: true });
140
+ const data = {};
141
+ for (const [chatId, cost] of this.costs) {
142
+ data[String(chatId)] = cost;
143
+ }
144
+ writeFileSync(this.storePath, JSON.stringify(data, null, 2));
145
+ }
146
+ catch (err) {
147
+ console.error("[costs] persist error:", err.message);
148
+ }
149
+ }
150
+ load() {
151
+ if (!existsSync(this.storePath))
152
+ return;
153
+ try {
154
+ const data = JSON.parse(readFileSync(this.storePath, "utf8"));
155
+ for (const [key, cost] of Object.entries(data)) {
156
+ this.costs.set(Number(key), cost);
157
+ }
158
+ console.log(`[costs] loaded ${this.costs.size} session costs from disk`);
159
+ }
160
+ catch (err) {
161
+ console.error("[costs] load error:", err.message);
162
+ }
163
+ }
164
+ }
165
+ export class CcTgBot {
166
+ bot;
167
+ sessions = new Map();
168
+ pendingRetries = new Map();
169
+ opts;
170
+ costStore;
171
+ botUsername = "";
172
+ botId = 0;
173
+ redis;
174
+ namespace;
175
+ lastActiveChatId;
176
+ cron;
177
+ constructor(opts) {
178
+ this.opts = opts;
179
+ this.redis = opts.redis;
180
+ this.namespace = opts.namespace ?? "default";
181
+ this.bot = new TelegramBot(opts.telegramToken, { polling: true });
182
+ this.bot.on("message", (msg) => this.handleTelegram(msg));
183
+ this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
184
+ this.bot.getMe().then((me) => {
185
+ this.botUsername = me.username ?? "";
186
+ this.botId = me.id;
187
+ console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
188
+ }).catch((err) => console.error("[tg] getMe failed:", err.message));
189
+ this.costStore = new CostStore(opts.cwd ?? process.cwd());
190
+ this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, _jobId, done) => {
191
+ this.runCronTask(chatId, prompt, done);
192
+ });
193
+ this.registerBotCommands();
194
+ console.log("cc-tg bot started");
195
+ console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
196
+ }
197
+ registerBotCommands() {
198
+ this.bot.setMyCommands(BOT_COMMANDS)
199
+ .then(() => console.log("[tg] bot commands registered"))
200
+ .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
201
+ }
202
+ /** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
203
+ writeChatMessage(role, source, content, chatId) {
204
+ if (!this.redis)
205
+ return;
206
+ const msg = {
207
+ id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
208
+ source,
209
+ role,
210
+ content,
211
+ timestamp: new Date().toISOString(),
212
+ chatId,
213
+ };
214
+ writeChatLog(this.redis, this.namespace, msg);
215
+ }
216
+ /** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
217
+ getLastActiveChatId() {
218
+ return this.lastActiveChatId;
219
+ }
220
+ /** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
221
+ sessionKey(chatId, threadId) {
222
+ return `${chatId}:${threadId ?? 'main'}`;
223
+ }
224
+ /**
225
+ * Send a message back to the correct thread (or plain chat if no thread).
226
+ * When threadId is undefined, calls sendMessage with exactly 2 args to preserve
227
+ * backward-compatible call signatures (no extra options object).
228
+ */
229
+ replyToChat(chatId, text, threadId, opts) {
230
+ if (threadId !== undefined) {
231
+ return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
232
+ }
233
+ if (opts) {
234
+ return this.bot.sendMessage(chatId, text, opts);
235
+ }
236
+ return this.bot.sendMessage(chatId, text);
237
+ }
238
+ /** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
239
+ getThreadCwdMap() {
240
+ const raw = process.env.THREAD_CWD_MAP;
241
+ if (!raw)
242
+ return {};
243
+ try {
244
+ return JSON.parse(raw);
245
+ }
246
+ catch {
247
+ console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
248
+ return {};
249
+ }
250
+ }
251
+ isAllowed(userId) {
252
+ if (!this.opts.allowedUserIds?.length)
253
+ return true;
254
+ return this.opts.allowedUserIds.includes(userId);
255
+ }
256
+ async handleTelegram(msg) {
257
+ const chatId = msg.chat.id;
258
+ const userId = msg.from?.id ?? chatId;
259
+ // Forum topic thread_id — undefined for DMs and non-topic group messages
260
+ const threadId = msg.message_thread_id;
261
+ // Thread name is available on the service message that creates a new topic.
262
+ // forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
263
+ const rawMsg = msg;
264
+ const threadName = rawMsg.forum_topic_created
265
+ ? rawMsg.forum_topic_created.name
266
+ : undefined;
267
+ if (!this.isAllowed(userId)) {
268
+ await this.replyToChat(chatId, "Not authorized.", threadId);
269
+ return;
270
+ }
271
+ // Track the last chat that sent us a message for the chat bridge
272
+ this.lastActiveChatId = chatId;
273
+ // Group chat handling
274
+ const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
275
+ if (isGroup) {
276
+ // If GROUP_CHAT_IDS allowlist is set, only respond in those chats
277
+ if (this.opts.groupChatIds?.length && !this.opts.groupChatIds.includes(chatId)) {
278
+ return;
279
+ }
280
+ // Only respond if: bot is @mentioned, message is a reply to the bot, or text starts with /
281
+ const text = msg.text?.trim() ?? "";
282
+ const isMentioned = this.botUsername && text.includes(`@${this.botUsername}`);
283
+ const isReplyToBot = msg.reply_to_message?.from?.id === this.botId;
284
+ const isCommand = text.startsWith("/");
285
+ if (!isMentioned && !isReplyToBot && !isCommand) {
286
+ return;
287
+ }
288
+ }
289
+ // Voice message — transcribe then feed as text
290
+ if (msg.voice || msg.audio) {
291
+ await this.handleVoice(chatId, msg, threadId, threadName);
292
+ return;
293
+ }
294
+ // Photo — send as base64 image content block to Claude
295
+ if (msg.photo?.length) {
296
+ await this.handlePhoto(chatId, msg, threadId, threadName);
297
+ return;
298
+ }
299
+ // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
300
+ if (msg.document) {
301
+ await this.handleDocument(chatId, msg, threadId, threadName);
302
+ return;
303
+ }
304
+ let text = msg.text?.trim();
305
+ if (!text)
306
+ return;
307
+ // Strip @botname mention prefix in group chats
308
+ if (this.botUsername) {
309
+ text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
310
+ }
311
+ const sessionKey = this.sessionKey(chatId, threadId);
312
+ // /start or /reset — kill existing session and ack
313
+ if (text === "/start" || text === "/reset") {
314
+ this.killSession(chatId, true, threadId);
315
+ await this.replyToChat(chatId, "Session reset. Send a message to start.", threadId);
316
+ return;
317
+ }
318
+ // /stop — kill active session (interrupt running Claude task)
319
+ if (text === "/stop") {
320
+ const has = this.sessions.has(sessionKey);
321
+ this.killSession(chatId, true, threadId);
322
+ await this.replyToChat(chatId, has ? "Stopped." : "No active session.", threadId);
323
+ return;
324
+ }
325
+ // /help — list all commands
326
+ if (text === "/help") {
327
+ const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
328
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
329
+ return;
330
+ }
331
+ // /status
332
+ if (text === "/status") {
333
+ const has = this.sessions.has(sessionKey);
334
+ let status = has ? "Session active." : "No active session.";
335
+ const sleeping = this.pendingRetries.size;
336
+ if (sleeping > 0)
337
+ status += `\n⏸ ${sleeping} request(s) sleeping (usage limit).`;
338
+ await this.replyToChat(chatId, status, threadId);
339
+ return;
340
+ }
341
+ // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
342
+ if (text === "/reload_mcp") {
343
+ await this.handleReloadMcp(chatId, threadId);
344
+ return;
345
+ }
346
+ // /mcp_status — run `claude mcp list` and show connection status
347
+ if (text === "/mcp_status") {
348
+ await this.handleMcpStatus(chatId, threadId);
349
+ return;
350
+ }
351
+ // /mcp_version — show published npm version and cached npx entries
352
+ if (text === "/mcp_version") {
353
+ await this.handleMcpVersion(chatId, threadId);
354
+ return;
355
+ }
356
+ // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
357
+ if (text === "/clear_npx_cache") {
358
+ await this.handleClearNpxCache(chatId, threadId);
359
+ return;
360
+ }
361
+ // /restart — restart the bot process in-place
362
+ if (text === "/restart") {
363
+ await this.handleRestart(chatId, threadId);
364
+ return;
365
+ }
366
+ // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
367
+ if (text.startsWith("/cron")) {
368
+ await this.handleCron(chatId, text, threadId);
369
+ return;
370
+ }
371
+ // /get_file <path> — send a file from the server to the user
372
+ if (text.startsWith("/get_file")) {
373
+ await this.handleGetFile(chatId, text, threadId);
374
+ return;
375
+ }
376
+ // /cost — show session token usage and cost
377
+ if (text === "/cost") {
378
+ const cost = this.costStore.get(chatId);
379
+ let reply = formatCostReport(cost);
380
+ try {
381
+ const rawSummary = await this.callCcAgentTool("cost_summary");
382
+ if (rawSummary) {
383
+ reply += "\n\n" + formatAgentCostSummary(rawSummary);
384
+ }
385
+ }
386
+ catch (err) {
387
+ console.error("[cost] cc-agent cost_summary failed:", err.message);
388
+ }
389
+ await this.replyToChat(chatId, reply, threadId);
390
+ return;
391
+ }
392
+ // /skills — list available Claude skills from ~/.claude/skills/
393
+ if (text === "/skills") {
394
+ await this.replyToChat(chatId, listSkills(), threadId);
395
+ return;
396
+ }
397
+ // /voice_retry — retry failed voice message transcriptions
398
+ if (text === "/voice_retry") {
399
+ await this.handleVoiceRetry(chatId, threadId);
400
+ return;
401
+ }
402
+ // /drivers — list available agent drivers via cc-agent MCP
403
+ if (text === "/drivers") {
404
+ await this.handleDrivers(chatId, threadId);
405
+ return;
406
+ }
407
+ // /agents — show running meta-agents and their live status
408
+ if (text === "/agents") {
409
+ await this.handleAgents(chatId, threadId);
410
+ return;
411
+ }
412
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
413
+ try {
414
+ const enriched = await enrichPromptWithUrls(text);
415
+ const prompt = buildPromptWithReplyContext(enriched, msg);
416
+ session.currentPrompt = prompt;
417
+ session.claude.sendPrompt(stampPrompt(prompt));
418
+ this.startTyping(chatId, session);
419
+ this.writeChatMessage("user", "telegram", text, chatId);
420
+ }
421
+ catch (err) {
422
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
423
+ this.killSession(chatId, true, threadId);
424
+ }
425
+ }
426
+ /**
427
+ * Feed a text message into the active Claude session for the given chat.
428
+ * Called by the notifier when a UI message arrives via Redis pub/sub.
429
+ */
430
+ async handleUserMessage(chatId, text) {
431
+ const session = this.getOrCreateSession(chatId);
432
+ try {
433
+ const enriched = await enrichPromptWithUrls(text);
434
+ session.currentPrompt = enriched;
435
+ session.claude.sendPrompt(stampPrompt(enriched));
436
+ this.startTyping(chatId, session);
437
+ this.writeChatMessage("user", "ui", text, chatId);
438
+ }
439
+ catch (err) {
440
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
441
+ this.killSession(chatId, true);
442
+ }
443
+ }
444
+ async handleVoice(chatId, msg, threadId, threadName) {
445
+ const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
446
+ if (!fileId)
447
+ return;
448
+ console.log(`[voice:${chatId}] received voice message, transcribing...`);
449
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
450
+ // Store in Redis before transcription so we can retry on failure
451
+ const pendingEntry = JSON.stringify({
452
+ file_id: fileId,
453
+ chat_id: chatId,
454
+ message_id: msg.message_id,
455
+ timestamp: Date.now(),
456
+ });
457
+ if (this.redis) {
458
+ await this.redis.rpush("voice:pending", pendingEntry).catch((err) => console.warn("[voice] redis rpush voice:pending failed:", err.message));
459
+ }
460
+ try {
461
+ const fileLink = await this.bot.getFileLink(fileId);
462
+ const transcript = await transcribeVoice(fileLink);
463
+ console.log(`[voice:${chatId}] transcribed: ${transcript}`);
464
+ // Remove from pending on success
465
+ if (this.redis) {
466
+ await this.redis.lrem("voice:pending", 0, pendingEntry).catch((err) => console.warn("[voice] redis lrem voice:pending failed:", err.message));
467
+ }
468
+ if (!transcript || transcript === "[empty transcription]") {
469
+ await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
470
+ return;
471
+ }
472
+ // Feed transcript into Claude as if user typed it
473
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
474
+ try {
475
+ const prompt = buildPromptWithReplyContext(transcript, msg);
476
+ this.writeChatMessage("user", "telegram", transcript, chatId);
477
+ session.currentPrompt = prompt;
478
+ session.claude.sendPrompt(stampPrompt(prompt));
479
+ this.startTyping(chatId, session);
480
+ }
481
+ catch (err) {
482
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
483
+ this.killSession(chatId, true, threadId);
484
+ }
485
+ }
486
+ catch (err) {
487
+ const errMsg = err.message;
488
+ console.error(`[voice:${chatId}] error:`, errMsg);
489
+ // Push to voice:failed on failure (entry stays in voice:pending for retry)
490
+ if (this.redis) {
491
+ const failedEntry = JSON.stringify({
492
+ file_id: fileId,
493
+ chat_id: chatId,
494
+ message_id: msg.message_id,
495
+ timestamp: Date.now(),
496
+ error: errMsg,
497
+ failed_at: Date.now(),
498
+ });
499
+ this.redis.rpush("voice:failed", failedEntry)
500
+ .then(() => this.redis.expire("voice:failed", 48 * 60 * 60))
501
+ .catch((redisErr) => console.warn("[voice] redis write voice:failed failed:", redisErr.message));
502
+ }
503
+ // User-friendly error messages
504
+ let userMsg;
505
+ if (errMsg.includes("whisper-cpp not found") || errMsg.includes("whisper not found")) {
506
+ userMsg = "Voice transcription unavailable — whisper-cpp not installed";
507
+ }
508
+ else if (errMsg.includes("No whisper model found")) {
509
+ userMsg = "Voice transcription unavailable — no whisper model found";
510
+ }
511
+ else if (errMsg.includes("HTTP") && errMsg.includes("downloading")) {
512
+ userMsg = "Could not download voice file from Telegram";
513
+ }
514
+ else {
515
+ userMsg = `Voice transcription failed: ${errMsg}`;
516
+ }
517
+ await this.replyToChat(chatId, userMsg, threadId);
518
+ }
519
+ }
520
+ async handleVoiceRetry(chatId, threadId) {
521
+ if (!this.redis) {
522
+ await this.replyToChat(chatId, "Redis not configured — voice retry unavailable.", threadId);
523
+ return;
524
+ }
525
+ const [pendingRaw, failedRaw] = await Promise.all([
526
+ this.redis.lrange("voice:pending", 0, -1).catch(() => []),
527
+ this.redis.lrange("voice:failed", 0, -1).catch(() => []),
528
+ ]);
529
+ // Deduplicate by file_id across both lists
530
+ const allEntries = new Map();
531
+ for (const raw of [...pendingRaw, ...failedRaw]) {
532
+ try {
533
+ const entry = JSON.parse(raw);
534
+ if (entry.file_id)
535
+ allEntries.set(entry.file_id, entry);
536
+ }
537
+ catch { /* skip malformed entries */ }
538
+ }
539
+ if (allEntries.size === 0) {
540
+ await this.replyToChat(chatId, "No pending voice messages to retry.", threadId);
541
+ return;
542
+ }
543
+ await this.replyToChat(chatId, `Retrying ${allEntries.size} voice message(s)...`, threadId);
544
+ let succeeded = 0;
545
+ let failed = 0;
546
+ const errors = [];
547
+ for (const [fileId, entry] of allEntries) {
548
+ try {
549
+ const fileLink = await this.bot.getFileLink(fileId);
550
+ const transcript = await transcribeVoice(fileLink);
551
+ if (transcript && transcript !== "[empty transcription]") {
552
+ const session = this.getOrCreateSession(entry.chat_id, threadId, undefined);
553
+ session.claude.sendPrompt(stampPrompt(transcript));
554
+ this.writeChatMessage("user", "telegram", transcript, entry.chat_id);
555
+ // Remove from both lists
556
+ const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
557
+ const matchFailed = failedRaw.find((r) => r.includes(`"${fileId}"`));
558
+ if (matchPending)
559
+ await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
560
+ if (matchFailed)
561
+ await this.redis.lrem("voice:failed", 0, matchFailed).catch(() => { });
562
+ succeeded++;
563
+ }
564
+ else {
565
+ failed++;
566
+ errors.push(`${fileId}: empty transcription`);
567
+ }
568
+ }
569
+ catch (err) {
570
+ const errMsg = err.message;
571
+ failed++;
572
+ errors.push(`${fileId}: ${errMsg}`);
573
+ // Permanently unretryable (expired Telegram link) — remove from voice:pending
574
+ if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
575
+ const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
576
+ if (matchPending)
577
+ await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
578
+ }
579
+ }
580
+ }
581
+ // Purge stale entries from voice:pending older than 48h
582
+ const staleThreshold = 48 * 60 * 60 * 1000;
583
+ let purged = 0;
584
+ for (const raw of pendingRaw) {
585
+ try {
586
+ const entry = JSON.parse(raw);
587
+ if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
588
+ await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
589
+ purged++;
590
+ }
591
+ }
592
+ catch { /* skip malformed entries */ }
593
+ }
594
+ const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
595
+ if (errors.length > 0)
596
+ lines.push(...errors.map((e) => `• ${e}`));
597
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
598
+ }
599
+ async handlePhoto(chatId, msg, threadId, threadName) {
600
+ // Pick highest resolution photo
601
+ const photos = msg.photo;
602
+ const best = photos[photos.length - 1];
603
+ const caption = msg.caption?.trim();
604
+ console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
605
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
606
+ try {
607
+ const fileLink = await this.bot.getFileLink(best.file_id);
608
+ const imageData = await fetchAsBase64(fileLink);
609
+ // Telegram photos are always JPEG
610
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
611
+ session.claude.sendImage(imageData, "image/jpeg", stampPrompt(caption ?? ""));
612
+ this.startTyping(chatId, session);
613
+ }
614
+ catch (err) {
615
+ console.error(`[photo:${chatId}] error:`, err.message);
616
+ await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
617
+ }
618
+ }
619
+ async handleDocument(chatId, msg, threadId, threadName) {
620
+ const doc = msg.document;
621
+ const caption = msg.caption?.trim();
622
+ const fileName = doc.file_name ?? `file_${doc.file_id}`;
623
+ console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
624
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
625
+ try {
626
+ const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
627
+ mkdirSync(uploadsDir, { recursive: true });
628
+ const destPath = join(uploadsDir, fileName);
629
+ const fileLink = await this.bot.getFileLink(doc.file_id);
630
+ await downloadToFile(fileLink, destPath);
631
+ console.log(`[doc:${chatId}] saved to ${destPath}`);
632
+ const prompt = caption
633
+ ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
634
+ : `ATTACHMENTS: [${fileName}](${destPath})`;
635
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
636
+ session.claude.sendPrompt(stampPrompt(prompt));
637
+ this.startTyping(chatId, session);
638
+ }
639
+ catch (err) {
640
+ console.error(`[doc:${chatId}] error:`, err.message);
641
+ await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
642
+ }
643
+ }
644
+ getOrCreateSession(chatId, threadId, threadName) {
645
+ const key = this.sessionKey(chatId, threadId);
646
+ const existing = this.sessions.get(key);
647
+ if (existing && !existing.claude.exited)
648
+ return existing;
649
+ // Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
650
+ let sessionCwd = this.opts.cwd;
651
+ const threadCwdMap = this.getThreadCwdMap();
652
+ if (threadName && threadCwdMap[threadName]) {
653
+ sessionCwd = threadCwdMap[threadName];
654
+ console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
655
+ }
656
+ else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
657
+ sessionCwd = threadCwdMap[String(threadId)];
658
+ console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
659
+ }
660
+ const claude = new ClaudeProcess({
661
+ cwd: sessionCwd,
662
+ token: getCurrentToken() || this.opts.claudeToken,
663
+ });
664
+ const session = {
665
+ claude,
666
+ pendingText: "",
667
+ flushTimer: null,
668
+ typingTimer: null,
669
+ writtenFiles: new Set(),
670
+ currentPrompt: "",
671
+ isRetry: false,
672
+ threadId,
673
+ };
674
+ claude.on("usage", (usage) => {
675
+ this.costStore.addUsage(chatId, usage);
676
+ });
677
+ claude.on("message", (msg) => {
678
+ // Verbose logging — log every message type and subtype
679
+ const subtype = msg.payload.subtype ?? "";
680
+ const toolName = this.extractToolName(msg);
681
+ const logParts = [`[claude:${key}] msg=${msg.type}`];
682
+ if (subtype)
683
+ logParts.push(`subtype=${subtype}`);
684
+ if (toolName)
685
+ logParts.push(`tool=${toolName}`);
686
+ console.log(logParts.join(" "));
687
+ // Track files written by Write/Edit tool calls
688
+ this.trackWrittenFiles(msg, session, sessionCwd);
689
+ // Publish tool call events to the chat log
690
+ if (msg.type === "assistant") {
691
+ const message = msg.payload.message;
692
+ const content = message?.content;
693
+ if (Array.isArray(content)) {
694
+ for (const block of content) {
695
+ if (block.type !== "tool_use")
696
+ continue;
697
+ const name = block.name;
698
+ const input = block.input;
699
+ this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
700
+ }
701
+ }
702
+ }
703
+ this.handleClaudeMessage(chatId, session, msg);
704
+ });
705
+ claude.on("stderr", (data) => {
706
+ const line = data.trim();
707
+ if (line)
708
+ console.error(`[claude:${key}:stderr]`, line);
709
+ });
710
+ claude.on("exit", (code) => {
711
+ console.log(`[claude:${key}] exited code=${code}`);
712
+ this.stopTyping(session);
713
+ this.sessions.delete(key);
714
+ });
715
+ claude.on("error", (err) => {
716
+ console.error(`[claude:${key}] process error: ${err.message}`);
717
+ this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
718
+ this.stopTyping(session);
719
+ this.sessions.delete(key);
720
+ });
721
+ this.sessions.set(key, session);
722
+ return session;
723
+ }
724
+ handleClaudeMessage(chatId, session, msg) {
725
+ // Use only the final `result` message — it contains the complete response text.
726
+ // Ignore `assistant` streaming chunks to avoid duplicates.
727
+ if (msg.type !== "result")
728
+ return;
729
+ this.stopTyping(session);
730
+ this.costStore.incrementMessages(chatId);
731
+ const text = extractText(msg);
732
+ if (!text)
733
+ return;
734
+ // Check for usage/rate limit signals before forwarding to Telegram
735
+ const sig = detectUsageLimit(text);
736
+ if (sig.detected) {
737
+ const threadId = session.threadId;
738
+ const retryKey = this.sessionKey(chatId, threadId);
739
+ const lastPrompt = session.currentPrompt;
740
+ const prevRetry = this.pendingRetries.get(retryKey);
741
+ const attempt = (prevRetry?.attempt ?? 0) + 1;
742
+ if (prevRetry)
743
+ clearTimeout(prevRetry.timer);
744
+ this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
745
+ this.killSession(chatId, true, threadId);
746
+ // Token rotation: if this is a usage_exhausted signal and we have multiple
747
+ // tokens, rotate to the next one and retry immediately instead of sleeping.
748
+ // Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
749
+ if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
750
+ const prevIdx = getTokenIndex();
751
+ rotateToken();
752
+ const newIdx = getTokenIndex();
753
+ const total = getTokenCount();
754
+ console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
755
+ this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
756
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
757
+ try {
758
+ const retrySession = this.getOrCreateSession(chatId, threadId);
759
+ retrySession.currentPrompt = lastPrompt;
760
+ retrySession.isRetry = true;
761
+ retrySession.claude.sendPrompt(stampPrompt(lastPrompt));
762
+ this.startTyping(chatId, retrySession);
763
+ }
764
+ catch (err) {
765
+ this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
766
+ }
767
+ return;
768
+ }
769
+ if (attempt > 3) {
770
+ this.replyToChat(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
771
+ this.pendingRetries.delete(retryKey);
772
+ return;
773
+ }
774
+ console.log(`[usage-limit:${retryKey}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
775
+ const timer = setTimeout(() => {
776
+ this.pendingRetries.delete(retryKey);
777
+ try {
778
+ const retrySession = this.getOrCreateSession(chatId, threadId);
779
+ retrySession.currentPrompt = lastPrompt;
780
+ retrySession.isRetry = true;
781
+ retrySession.claude.sendPrompt(stampPrompt(lastPrompt));
782
+ this.startTyping(chatId, retrySession);
783
+ }
784
+ catch (err) {
785
+ this.replyToChat(chatId, `❌ Failed to retry: ${err.message}`, threadId).catch(() => { });
786
+ }
787
+ }, sig.retryAfterMs);
788
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
789
+ return;
790
+ }
791
+ // Accumulate text and debounce — Claude streams chunks rapidly
792
+ session.pendingText += text;
793
+ if (session.flushTimer)
794
+ clearTimeout(session.flushTimer);
795
+ session.flushTimer = setTimeout(() => this.flushPending(chatId, session), FLUSH_DELAY_MS);
796
+ }
797
+ startTyping(chatId, session) {
798
+ this.stopTyping(session);
799
+ // Send immediately, then keep alive every 4s
800
+ // Pass message_thread_id so typing appears in the correct forum topic thread
801
+ const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
802
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
803
+ session.typingTimer = setInterval(() => {
804
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
805
+ }, TYPING_INTERVAL_MS);
806
+ }
807
+ stopTyping(session) {
808
+ if (session.typingTimer) {
809
+ clearInterval(session.typingTimer);
810
+ session.typingTimer = null;
811
+ }
812
+ }
813
+ flushPending(chatId, session) {
814
+ const raw = session.pendingText.trim();
815
+ session.pendingText = "";
816
+ session.flushTimer = null;
817
+ if (!raw)
818
+ return;
819
+ this.writeChatMessage("assistant", "cc-tg", raw, chatId);
820
+ const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
821
+ session.isRetry = false;
822
+ // Format for Telegram HTML and split if needed (max 4096 chars)
823
+ const formatted = formatForTelegram(text);
824
+ const chunks = splitLongMessage(formatted);
825
+ const threadId = session.threadId;
826
+ for (const chunk of chunks) {
827
+ this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
828
+ // HTML parse failed — retry as plain text
829
+ this.replyToChat(chatId, chunk, threadId).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
830
+ });
831
+ }
832
+ // Hybrid file upload: find files mentioned in result text that Claude actually wrote
833
+ try {
834
+ this.uploadMentionedFiles(chatId, text, session);
835
+ }
836
+ catch (err) {
837
+ console.error(`[tg:${chatId}] uploadMentionedFiles error:`, err.message);
838
+ }
839
+ }
840
+ trackWrittenFiles(msg, session, cwd) {
841
+ // Only look at assistant messages with tool_use blocks
842
+ if (msg.type !== "assistant")
843
+ return;
844
+ const message = msg.payload.message;
845
+ if (!message)
846
+ return;
847
+ const content = message.content;
848
+ if (!Array.isArray(content))
849
+ return;
850
+ for (const block of content) {
851
+ if (block.type !== "tool_use")
852
+ continue;
853
+ const name = block.name;
854
+ const input = block.input;
855
+ if (!input)
856
+ continue;
857
+ if (["Write", "Edit", "NotebookEdit"].includes(name)) {
858
+ // Write tool uses file_path, Edit uses file_path
859
+ const filePath = input.file_path ?? input.path;
860
+ if (!filePath)
861
+ continue;
862
+ // Resolve relative paths against cwd
863
+ const resolved = filePath.startsWith("/")
864
+ ? filePath
865
+ : resolve(cwd ?? process.cwd(), filePath);
866
+ console.log(`[claude:files] tracked written file: ${resolved}`);
867
+ session.writtenFiles.add(resolved);
868
+ }
869
+ else if (name === "Bash") {
870
+ const cmd = input.command ?? "";
871
+ if (/\byt-dlp\b|\bffmpeg\b/.test(cmd)) {
872
+ // Scan output dir for recently modified media files (template paths like /tmp/%(title)s.%(ext)s
873
+ // make the actual filename unknowable at tracking time)
874
+ const oFlagMatch = cmd.match(/-o\s+["']?([^\s"']+)/);
875
+ let scanDir = "/tmp/";
876
+ if (oFlagMatch) {
877
+ const oPath = oFlagMatch[1].replace(/["'].*$/, "");
878
+ const dirEnd = oPath.lastIndexOf("/");
879
+ if (dirEnd > 0)
880
+ scanDir = oPath.slice(0, dirEnd + 1);
881
+ }
882
+ const MEDIA_EXTS = new Set([".mp3", ".mp4", ".wav", ".ogg", ".flac", ".webm", ".m4a", ".aac"]);
883
+ const nowMs = Date.now();
884
+ try {
885
+ for (const entry of readdirSync(scanDir)) {
886
+ const dotIdx = entry.lastIndexOf(".");
887
+ if (dotIdx < 0)
888
+ continue;
889
+ const ext = entry.slice(dotIdx).toLowerCase();
890
+ if (!MEDIA_EXTS.has(ext))
891
+ continue;
892
+ const full = join(scanDir, entry);
893
+ try {
894
+ if (nowMs - statSync(full).mtimeMs <= 90_000) {
895
+ console.log(`[claude:files] tracked yt-dlp/ffmpeg output: ${full}`);
896
+ session.writtenFiles.add(full);
897
+ }
898
+ }
899
+ catch { /* skip unreadable entries */ }
900
+ }
901
+ }
902
+ catch { /* scanDir doesn't exist or unreadable */ }
903
+ }
904
+ else {
905
+ // Other bash commands: try to extract output path from -o flag
906
+ const oFlag = cmd.match(/-o\s+["']?([^\s"']+\.[\w]{1,10})["']?/);
907
+ if (oFlag)
908
+ session.writtenFiles.add(resolve(cwd ?? process.cwd(), oFlag[1]));
909
+ }
910
+ // mv source dest — track dest
911
+ const mvMatch = cmd.match(/\bmv\s+\S+\s+["']?([^\s"']+)["']?$/);
912
+ if (mvMatch)
913
+ session.writtenFiles.add(resolve(cwd ?? process.cwd(), mvMatch[1]));
914
+ // cp source dest — track dest
915
+ const cpMatch = cmd.match(/\bcp\s+\S+\s+["']?([^\s"']+)["']?$/);
916
+ if (cpMatch)
917
+ session.writtenFiles.add(resolve(cwd ?? process.cwd(), cpMatch[1]));
918
+ // curl -o path or wget -O path
919
+ const curlMatch = cmd.match(/curl\s+.*?-o\s+["']?([^\s"']+)["']?/);
920
+ if (curlMatch)
921
+ session.writtenFiles.add(resolve(cwd ?? process.cwd(), curlMatch[1]));
922
+ // wget -O path
923
+ const wgetMatch = cmd.match(/wget\s+.*?-O\s+["']?([^\s"']+)["']?/);
924
+ if (wgetMatch)
925
+ session.writtenFiles.add(resolve(cwd ?? process.cwd(), wgetMatch[1]));
926
+ }
927
+ }
928
+ }
929
+ uploadMentionedFiles(chatId, resultText, session) {
930
+ // Extract file path candidates from result text
931
+ // Match: /absolute/path/file.ext or relative like ./foo/bar.csv or just foo.pdf
932
+ const pathPattern = /(?:^|[\s`'"(])(\/?[\w.\-/]+\.[\w]{1,10})(?:[\s`'")\n]|$)/gm;
933
+ const quotedPattern = /"([^"]+\.[a-zA-Z0-9]{1,10})"|'([^']+\.[a-zA-Z0-9]{1,10})'/g;
934
+ const candidates = new Set();
935
+ let match;
936
+ while ((match = pathPattern.exec(resultText)) !== null) {
937
+ candidates.add(match[1]);
938
+ }
939
+ while ((match = quotedPattern.exec(resultText)) !== null) {
940
+ candidates.add(match[1] ?? match[2]);
941
+ }
942
+ const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/"];
943
+ const isSafeDir = (p) => safeDirs.some(d => p.startsWith(d)) || p.startsWith(this.opts.cwd ?? process.cwd());
944
+ const toUpload = [];
945
+ if (session.writtenFiles.size > 0) {
946
+ for (const candidate of candidates) {
947
+ // Try as-is (absolute), or resolve against cwd
948
+ const resolved = candidate.startsWith("/")
949
+ ? candidate
950
+ : resolve(this.opts.cwd ?? process.cwd(), candidate);
951
+ if (session.writtenFiles.has(resolved) && existsSync(resolved)) {
952
+ toUpload.push(resolved);
953
+ }
954
+ else {
955
+ // Also check by basename — result might mention just the filename
956
+ for (const written of session.writtenFiles) {
957
+ if (basename(written) === basename(candidate) && existsSync(written)) {
958
+ toUpload.push(written);
959
+ break;
960
+ }
961
+ }
962
+ }
963
+ }
964
+ }
965
+ // Also upload files mentioned in result text that exist in safe dirs
966
+ // even if not tracked via Write tool
967
+ for (const candidate of candidates) {
968
+ const resolved = candidate.startsWith("/")
969
+ ? candidate
970
+ : resolve(this.opts.cwd ?? process.cwd(), candidate);
971
+ if (existsSync(resolved) && isSafeDir(resolved) && !toUpload.includes(resolved)) {
972
+ toUpload.push(resolved);
973
+ }
974
+ }
975
+ const unique = [...new Set(toUpload)];
976
+ for (const filePath of unique) {
977
+ let fileSize;
978
+ try {
979
+ fileSize = statSync(filePath).size;
980
+ }
981
+ catch {
982
+ continue; // file disappeared between existsSync and statSync
983
+ }
984
+ const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
985
+ if (fileSize > MAX_TG_FILE_BYTES) {
986
+ const mb = (fileSize / (1024 * 1024)).toFixed(1);
987
+ this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, session.threadId).catch(() => { });
988
+ continue;
989
+ }
990
+ console.log(`[claude:files] uploading to telegram: ${filePath}`);
991
+ const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
992
+ this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
993
+ }
994
+ // Clear written files for next turn
995
+ session.writtenFiles.clear();
996
+ }
997
+ extractToolName(msg) {
998
+ const message = msg.payload.message;
999
+ if (!message)
1000
+ return "";
1001
+ const content = message.content;
1002
+ if (!Array.isArray(content))
1003
+ return "";
1004
+ const toolUse = content.find((b) => b.type === "tool_use");
1005
+ return toolUse?.name ?? "";
1006
+ }
1007
+ /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
1008
+ findCcAgentPids() {
1009
+ try {
1010
+ const out = execSync("pgrep -f cc-agent", { encoding: "utf8" }).trim();
1011
+ return out.split("\n").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n) && n > 0);
1012
+ }
1013
+ catch {
1014
+ // pgrep exits with code 1 when no match — that's fine
1015
+ return [];
1016
+ }
1017
+ }
1018
+ /** Kill cc-agent PIDs with SIGTERM. Returns the list of killed PIDs. */
1019
+ killCcAgent() {
1020
+ const pids = this.findCcAgentPids();
1021
+ for (const pid of pids) {
1022
+ try {
1023
+ process.kill(pid, "SIGTERM");
1024
+ console.log(`[mcp] sent SIGTERM to cc-agent pid=${pid}`);
1025
+ }
1026
+ catch (err) {
1027
+ console.warn(`[mcp] failed to kill pid=${pid}:`, err.message);
1028
+ }
1029
+ }
1030
+ return pids;
1031
+ }
1032
+ async handleReloadMcp(chatId, threadId) {
1033
+ await this.replyToChat(chatId, "Clearing npx cache and reloading MCP...", threadId);
1034
+ try {
1035
+ const home = process.env.HOME ?? "~";
1036
+ execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
1037
+ console.log("[mcp] cleared ~/.npm/_npx/");
1038
+ }
1039
+ catch (err) {
1040
+ await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
1041
+ }
1042
+ const pids = this.killCcAgent();
1043
+ if (pids.length === 0) {
1044
+ await this.replyToChat(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.", threadId);
1045
+ return;
1046
+ }
1047
+ await this.replyToChat(chatId, `NPX cache cleared. Sent SIGTERM to cc-agent (pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}).\nMCP restarted. New process will load on next agent call.`, threadId);
1048
+ }
1049
+ async handleMcpStatus(chatId, threadId) {
1050
+ try {
1051
+ const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
1052
+ await this.replyToChat(chatId, `MCP server status:\n\n${output || "(no output)"}`, threadId);
1053
+ }
1054
+ catch (err) {
1055
+ await this.replyToChat(chatId, `Failed to run claude mcp list: ${err.message}`, threadId);
1056
+ }
1057
+ }
1058
+ async handleMcpVersion(chatId, threadId) {
1059
+ let npmVersion = "unknown";
1060
+ let cacheEntries = "(unavailable)";
1061
+ try {
1062
+ npmVersion = execSync("npm view @gonzih/cc-agent version", { encoding: "utf8" }).trim();
1063
+ }
1064
+ catch (err) {
1065
+ npmVersion = `error: ${err.message.split("\n")[0]}`;
1066
+ }
1067
+ try {
1068
+ const home = process.env.HOME ?? "~";
1069
+ const cacheOut = execSync(`ls "${home}/.npm/_npx/" 2>/dev/null | head -5`, { encoding: "utf8", shell: "/bin/sh" }).trim();
1070
+ cacheEntries = cacheOut || "(empty)";
1071
+ }
1072
+ catch {
1073
+ cacheEntries = "(empty or not found)";
1074
+ }
1075
+ await this.replyToChat(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`, threadId);
1076
+ }
1077
+ async handleClearNpxCache(chatId, threadId) {
1078
+ const home = process.env.HOME ?? "/tmp";
1079
+ const cleared = [];
1080
+ const failed = [];
1081
+ // Clear both npx execution cache and full npm package cache
1082
+ for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
1083
+ try {
1084
+ execSync(`rm -rf "${dir}"`, { encoding: "utf8", shell: "/bin/sh" });
1085
+ cleared.push(dir.replace(home, "~"));
1086
+ console.log(`[cache] cleared ${dir}`);
1087
+ }
1088
+ catch (err) {
1089
+ failed.push(dir.replace(home, "~"));
1090
+ console.warn(`[cache] failed to clear ${dir}:`, err.message);
1091
+ }
1092
+ }
1093
+ const pids = this.killCcAgent();
1094
+ const pidNote = pids.length > 0
1095
+ ? ` Sent SIGTERM to cc-agent pid${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}.`
1096
+ : " No cc-agent running.";
1097
+ const clearNote = failed.length
1098
+ ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
1099
+ : `Cleared: ${cleared.join(", ")}.`;
1100
+ await this.replyToChat(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`, threadId);
1101
+ }
1102
+ async handleRestart(chatId, threadId) {
1103
+ await this.replyToChat(chatId, "Clearing cache and restarting... brb.", threadId);
1104
+ await new Promise(resolve => setTimeout(resolve, 300));
1105
+ // Clear npm caches before restart so launchd brings up fresh version
1106
+ const home = process.env.HOME ?? "/tmp";
1107
+ for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
1108
+ try {
1109
+ execSync(`rm -rf "${dir}"`, { shell: "/bin/sh" });
1110
+ }
1111
+ catch { }
1112
+ }
1113
+ // Kill all active Claude sessions cleanly
1114
+ for (const session of this.sessions.values()) {
1115
+ this.stopTyping(session);
1116
+ session.claude.kill();
1117
+ }
1118
+ this.sessions.clear();
1119
+ await new Promise(resolve => setTimeout(resolve, 200));
1120
+ process.exit(0);
1121
+ }
1122
+ async handleCron(chatId, text, threadId) {
1123
+ const args = text.slice("/cron".length).trim();
1124
+ if (args === "list" || args === "") {
1125
+ const jobs = this.cron.list(chatId);
1126
+ if (!jobs.length) {
1127
+ await this.replyToChat(chatId, "No cron jobs.", threadId);
1128
+ return;
1129
+ }
1130
+ const lines = jobs.map((j, i) => {
1131
+ const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
1132
+ return `#${i + 1} ${j.schedule} — "${short}"`;
1133
+ });
1134
+ await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
1135
+ return;
1136
+ }
1137
+ if (args === "clear") {
1138
+ const n = this.cron.clearAll(chatId);
1139
+ await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
1140
+ return;
1141
+ }
1142
+ if (args.startsWith("remove ")) {
1143
+ const id = args.slice("remove ".length).trim();
1144
+ const ok = this.cron.remove(chatId, id);
1145
+ await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
1146
+ return;
1147
+ }
1148
+ const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
1149
+ if (!scheduleMatch) {
1150
+ await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
1151
+ return;
1152
+ }
1153
+ const schedule = scheduleMatch[1];
1154
+ const prompt = scheduleMatch[2];
1155
+ const job = this.cron.add(chatId, schedule, prompt);
1156
+ if (!job) {
1157
+ await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
1158
+ return;
1159
+ }
1160
+ await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
1161
+ }
1162
+ runCronTask(chatId, prompt, done = () => { }) {
1163
+ const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
1164
+ cronProcess.sendPrompt(prompt);
1165
+ cronProcess.on("message", (msg) => {
1166
+ const result = extractText(msg);
1167
+ if (result) {
1168
+ const formatted = formatForTelegram(`🕐 ${result}`);
1169
+ const chunks = splitLongMessage(formatted);
1170
+ for (const chunk of chunks) {
1171
+ this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
1172
+ }
1173
+ }
1174
+ });
1175
+ cronProcess.on("exit", () => done());
1176
+ }
1177
+ async handleGetFile(chatId, text, threadId) {
1178
+ const arg = text.slice("/get_file".length).trim();
1179
+ if (!arg) {
1180
+ await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1181
+ return;
1182
+ }
1183
+ const filePath = resolve(arg);
1184
+ const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
1185
+ const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
1186
+ if (!inSafeDir) {
1187
+ await this.replyToChat(chatId, "Access denied: path not in allowed directories", threadId);
1188
+ return;
1189
+ }
1190
+ if (!existsSync(filePath)) {
1191
+ await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1192
+ return;
1193
+ }
1194
+ if (!statSync(filePath).isFile()) {
1195
+ await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1196
+ return;
1197
+ }
1198
+ const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1199
+ const fileSize = statSync(filePath).size;
1200
+ if (fileSize > MAX_TG_FILE_BYTES) {
1201
+ const mb = (fileSize / (1024 * 1024)).toFixed(1);
1202
+ await this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, threadId);
1203
+ return;
1204
+ }
1205
+ const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1206
+ await this.bot.sendDocument(chatId, filePath, docOpts);
1207
+ }
1208
+ async handleDrivers(chatId, threadId) {
1209
+ try {
1210
+ const raw = await this.callCcAgentTool("list_drivers");
1211
+ if (!raw) {
1212
+ await this.replyToChat(chatId, "No drivers available or cc-agent did not respond.", threadId);
1213
+ return;
1214
+ }
1215
+ // Try to pretty-print JSON array/object, fall back to raw string
1216
+ let reply;
1217
+ try {
1218
+ const data = JSON.parse(raw);
1219
+ if (Array.isArray(data)) {
1220
+ const current = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
1221
+ const lines = data.map((d) => d === current ? `• ${d} (default)` : `• ${d}`);
1222
+ reply = `Available drivers:\n${lines.join("\n")}`;
1223
+ }
1224
+ else {
1225
+ reply = `Available drivers:\n${raw}`;
1226
+ }
1227
+ }
1228
+ catch {
1229
+ reply = `Available drivers:\n${raw}`;
1230
+ }
1231
+ await this.replyToChat(chatId, reply, threadId);
1232
+ }
1233
+ catch (err) {
1234
+ await this.replyToChat(chatId, `Failed to list drivers: ${err.message}`, threadId);
1235
+ }
1236
+ }
1237
+ async handleAgents(chatId, threadId) {
1238
+ if (!this.redis) {
1239
+ await this.replyToChat(chatId, "Redis not configured — agents status unavailable.", threadId);
1240
+ return;
1241
+ }
1242
+ try {
1243
+ // Scan for all meta-agent status keys
1244
+ const keys = [];
1245
+ let cursor = "0";
1246
+ do {
1247
+ const [nextCursor, found] = await this.redis.scan(cursor, "MATCH", "cca:meta-agent:status:*", "COUNT", 100);
1248
+ cursor = nextCursor;
1249
+ keys.push(...found);
1250
+ } while (cursor !== "0");
1251
+ if (keys.length === 0) {
1252
+ await this.replyToChat(chatId, "No active meta-agents.", threadId);
1253
+ return;
1254
+ }
1255
+ const statuses = await Promise.all(keys.sort().map(async (key) => ({ key, raw: await this.redis.get(key) })));
1256
+ const lines = ["🤖 Active Agents", ""];
1257
+ for (const { key, raw } of statuses) {
1258
+ const namespace = key.replace("cca:meta-agent:status:", "");
1259
+ if (!raw) {
1260
+ lines.push(`${namespace} — status unknown`);
1261
+ continue;
1262
+ }
1263
+ try {
1264
+ const status = JSON.parse(raw);
1265
+ const state = status.status ?? "unknown";
1266
+ const turns = status.turn ?? status.turn_count ?? 0;
1267
+ const tool = status.current_tool;
1268
+ const lastActivity = status.last_activity ?? status.updated_at;
1269
+ let ageStr = "";
1270
+ if (lastActivity) {
1271
+ const ageSec = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
1272
+ if (ageSec < 60)
1273
+ ageStr = `${ageSec}s ago`;
1274
+ else if (ageSec < 3600)
1275
+ ageStr = `${Math.floor(ageSec / 60)}m ago`;
1276
+ else
1277
+ ageStr = `${Math.floor(ageSec / 3600)}h ago`;
1278
+ }
1279
+ let statusDesc;
1280
+ if (state === "running" && tool) {
1281
+ statusDesc = `typing... (turn ${turns})`;
1282
+ }
1283
+ else if (state === "running") {
1284
+ statusDesc = `running (turn ${turns}${ageStr ? `, ${ageStr}` : ""})`;
1285
+ }
1286
+ else {
1287
+ statusDesc = `idle (turn ${turns}${ageStr ? `, ${ageStr}` : ""})`;
1288
+ }
1289
+ lines.push(`${namespace} — ${statusDesc}`);
1290
+ }
1291
+ catch {
1292
+ lines.push(`${namespace} — status unknown`);
1293
+ }
1294
+ }
1295
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
1296
+ }
1297
+ catch (err) {
1298
+ await this.replyToChat(chatId, `Failed to get agents status: ${err.message}`, threadId);
1299
+ }
1300
+ }
1301
+ callCcAgentTool(toolName, args = {}) {
1302
+ // For spawn tools, pass through the configured driver and model
1303
+ const spawnTools = new Set(["spawn_agent", "spawn_from_profile"]);
1304
+ if (spawnTools.has(toolName)) {
1305
+ const driver = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
1306
+ const model = process.env.CC_AGENT_DEFAULT_MODEL || undefined;
1307
+ args = { agent_driver: driver, ...(model ? { agent_model: model } : {}), ...args };
1308
+ }
1309
+ return new Promise((resolve) => {
1310
+ let settled = false;
1311
+ const done = (val) => {
1312
+ if (!settled) {
1313
+ settled = true;
1314
+ resolve(val);
1315
+ }
1316
+ };
1317
+ let proc;
1318
+ try {
1319
+ proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
1320
+ env: { ...process.env },
1321
+ stdio: ["pipe", "pipe", "pipe"],
1322
+ });
1323
+ }
1324
+ catch (err) {
1325
+ console.error("[mcp] failed to spawn cc-agent:", err.message);
1326
+ done(null);
1327
+ return;
1328
+ }
1329
+ const timeout = setTimeout(() => {
1330
+ console.warn("[mcp] cc-agent tool call timed out");
1331
+ proc.kill();
1332
+ done(null);
1333
+ }, 30_000);
1334
+ let buffer = "";
1335
+ const sendMsg = (msg) => { proc.stdin.write(JSON.stringify(msg) + "\n"); };
1336
+ sendMsg({
1337
+ jsonrpc: "2.0", id: 1, method: "initialize",
1338
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "cc-tg", version: "1.0.0" } },
1339
+ });
1340
+ proc.stdout.on("data", (chunk) => {
1341
+ buffer += chunk.toString();
1342
+ const lines = buffer.split("\n");
1343
+ buffer = lines.pop() ?? "";
1344
+ for (const line of lines) {
1345
+ if (!line.trim())
1346
+ continue;
1347
+ try {
1348
+ const msg = JSON.parse(line);
1349
+ if (msg.id === 1 && "result" in msg) {
1350
+ sendMsg({ jsonrpc: "2.0", method: "notifications/initialized" });
1351
+ sendMsg({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: toolName, arguments: args } });
1352
+ }
1353
+ else if (msg.id === 2) {
1354
+ clearTimeout(timeout);
1355
+ if (msg.error) {
1356
+ console.error("[mcp] cost_summary error:", JSON.stringify(msg.error));
1357
+ proc.kill();
1358
+ done(null);
1359
+ return;
1360
+ }
1361
+ const result = msg.result;
1362
+ const content = result?.content;
1363
+ const text = (content ?? []).filter((b) => b.type === "text").map((b) => b.text).join("");
1364
+ proc.kill();
1365
+ done(text || null);
1366
+ }
1367
+ }
1368
+ catch { /* ignore non-JSON lines */ }
1369
+ }
1370
+ });
1371
+ proc.on("error", (err) => {
1372
+ console.error("[mcp] cc-agent spawn error:", err.message);
1373
+ clearTimeout(timeout);
1374
+ done(null);
1375
+ });
1376
+ proc.on("exit", () => { clearTimeout(timeout); done(null); });
1377
+ });
1378
+ }
1379
+ killSession(chatId, _keepCrons = true, threadId) {
1380
+ const key = this.sessionKey(chatId, threadId);
1381
+ const session = this.sessions.get(key);
1382
+ if (session) {
1383
+ this.stopTyping(session);
1384
+ session.claude.kill();
1385
+ this.sessions.delete(key);
1386
+ }
1387
+ }
1388
+ getMe() {
1389
+ return this.bot.getMe();
1390
+ }
1391
+ stop() {
1392
+ this.bot.stopPolling();
1393
+ for (const session of this.sessions.values()) {
1394
+ this.stopTyping(session);
1395
+ session.claude.kill();
1396
+ }
1397
+ this.sessions.clear();
1398
+ }
1399
+ }
1400
+ function buildPromptWithReplyContext(text, msg) {
1401
+ const reply = msg.reply_to_message;
1402
+ if (!reply)
1403
+ return text;
1404
+ const quotedText = reply.text || reply.caption || null;
1405
+ if (!quotedText)
1406
+ return text;
1407
+ const truncated = quotedText.length > 500
1408
+ ? quotedText.slice(0, 500) + "... [truncated]"
1409
+ : quotedText;
1410
+ return `[Replying to: "${truncated}"]\n\n${text}`;
1411
+ }
1412
+ /** Download a URL and return its contents as a base64 string */
1413
+ function fetchAsBase64(url) {
1414
+ return new Promise((resolve, reject) => {
1415
+ const client = url.startsWith("https") ? https : http;
1416
+ client.get(url, (res) => {
1417
+ const chunks = [];
1418
+ res.on("data", (chunk) => chunks.push(chunk));
1419
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("base64")));
1420
+ res.on("error", reject);
1421
+ }).on("error", reject);
1422
+ });
1423
+ }
1424
+ /** Download a URL to a local file path */
1425
+ function downloadToFile(url, destPath) {
1426
+ return new Promise((resolve, reject) => {
1427
+ const client = url.startsWith("https") ? https : http;
1428
+ const file = createWriteStream(destPath);
1429
+ client.get(url, (res) => {
1430
+ res.pipe(file);
1431
+ file.on("finish", () => file.close(() => resolve()));
1432
+ file.on("error", reject);
1433
+ }).on("error", reject);
1434
+ });
1435
+ }
1436
+ /** Fetch URL via Jina Reader and return first maxChars characters */
1437
+ function fetchUrlViaJina(url, maxChars = 2000) {
1438
+ const jinaUrl = `https://r.jina.ai/${url}`;
1439
+ return new Promise((resolve, reject) => {
1440
+ https.get(jinaUrl, (res) => {
1441
+ const chunks = [];
1442
+ res.on("data", (chunk) => chunks.push(chunk));
1443
+ res.on("end", () => {
1444
+ const text = Buffer.concat(chunks).toString("utf8");
1445
+ resolve(text.slice(0, maxChars));
1446
+ });
1447
+ res.on("error", reject);
1448
+ }).on("error", reject);
1449
+ });
1450
+ }
1451
+ /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
1452
+ export async function enrichPromptWithUrls(text) {
1453
+ const urlRegex = /https?:\/\/[^\s]+/g;
1454
+ const urls = text.match(urlRegex);
1455
+ if (!urls || urls.length === 0)
1456
+ return text;
1457
+ const prefixes = [];
1458
+ for (const url of urls) {
1459
+ // Skip jina.ai URLs to avoid recursion
1460
+ if (url.includes("r.jina.ai"))
1461
+ continue;
1462
+ try {
1463
+ const content = await fetchUrlViaJina(url);
1464
+ if (content.trim()) {
1465
+ prefixes.push(`[Web content from ${url}]:\n${content}`);
1466
+ }
1467
+ }
1468
+ catch (err) {
1469
+ console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
1470
+ }
1471
+ }
1472
+ if (prefixes.length === 0)
1473
+ return text;
1474
+ return prefixes.join("\n\n") + "\n\n" + text;
1475
+ }
1476
+ /** Parse frontmatter description from a skill markdown file */
1477
+ function parseSkillDescription(content) {
1478
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1479
+ if (!match)
1480
+ return null;
1481
+ const frontmatter = match[1];
1482
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
1483
+ return descMatch ? descMatch[1].trim() : null;
1484
+ }
1485
+ /** List available skills from ~/.claude/skills/ */
1486
+ export function listSkills() {
1487
+ const skillsDir = join(os.homedir(), ".claude", "skills");
1488
+ if (!existsSync(skillsDir)) {
1489
+ return "No skills directory found at ~/.claude/skills/";
1490
+ }
1491
+ let files;
1492
+ try {
1493
+ files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
1494
+ }
1495
+ catch {
1496
+ return "Could not read skills directory.";
1497
+ }
1498
+ if (files.length === 0) {
1499
+ return "No skills found in ~/.claude/skills/";
1500
+ }
1501
+ const lines = ["Available skills:"];
1502
+ for (const file of files.sort()) {
1503
+ const name = "/" + file.replace(/\.md$/, "");
1504
+ try {
1505
+ const content = readFileSync(join(skillsDir, file), "utf8");
1506
+ const description = parseSkillDescription(content);
1507
+ lines.push(description ? `${name} — ${description}` : name);
1508
+ }
1509
+ catch {
1510
+ lines.push(name);
1511
+ }
1512
+ }
1513
+ return lines.join("\n");
1514
+ }
1515
+ export function splitMessage(text, maxLen = 4096) {
1516
+ if (text.length <= maxLen)
1517
+ return [text];
1518
+ const chunks = [];
1519
+ let i = 0;
1520
+ while (i < text.length) {
1521
+ chunks.push(text.slice(i, i + maxLen));
1522
+ i += maxLen;
1523
+ }
1524
+ return chunks;
1525
+ }