@gonzih/cc-tg 0.9.21 → 0.9.23

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