@gonzih/cc-tg 0.9.20 → 0.9.22

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,11 @@ 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" },
31
36
  ];
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
37
  const FLUSH_DELAY_MS = 800; // debounce streaming chunks into one Telegram message
48
38
  const TYPING_INTERVAL_MS = 4000; // re-send typing action before Telegram's 5s expiry
49
39
  // Claude Sonnet 4.6 pricing (per 1M tokens)
@@ -79,10 +69,6 @@ function formatCostReport(cost) {
79
69
  ` Cache write: ${formatTokens(cost.totalCacheWriteTokens)} tokens ($${cacheWriteCost.toFixed(3)})`,
80
70
  ].join("\n");
81
71
  }
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
72
  function formatAgentCostSummary(text) {
87
73
  try {
88
74
  const data = JSON.parse(text);
@@ -169,12 +155,17 @@ export class CcTgBot {
169
155
  sessions = new Map();
170
156
  pendingRetries = new Map();
171
157
  opts;
172
- cron;
173
158
  costStore;
174
159
  botUsername = "";
175
160
  botId = 0;
161
+ redis;
162
+ namespace;
163
+ lastActiveChatId;
164
+ cron;
176
165
  constructor(opts) {
177
166
  this.opts = opts;
167
+ this.redis = opts.redis;
168
+ this.namespace = opts.namespace ?? "default";
178
169
  this.bot = new TelegramBot(opts.telegramToken, { polling: true });
179
170
  this.bot.on("message", (msg) => this.handleTelegram(msg));
180
171
  this.bot.on("polling_error", (err) => console.error("[tg]", err.message));
@@ -183,11 +174,10 @@ export class CcTgBot {
183
174
  this.botId = me.id;
184
175
  console.log(`[tg] bot identity: @${this.botUsername} (id=${this.botId})`);
185
176
  }).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
177
  this.costStore = new CostStore(opts.cwd ?? process.cwd());
178
+ this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatId, prompt, _jobId, done) => {
179
+ this.runCronTask(chatId, prompt, done);
180
+ });
191
181
  this.registerBotCommands();
192
182
  console.log("cc-tg bot started");
193
183
  console.log(`[voice] whisper available: ${isVoiceAvailable()}`);
@@ -197,6 +187,55 @@ export class CcTgBot {
197
187
  .then(() => console.log("[tg] bot commands registered"))
198
188
  .catch((err) => console.error("[tg] setMyCommands failed:", err.message));
199
189
  }
190
+ /** Write a message to the Redis chat log. Fire-and-forget — no-op if Redis is not configured. */
191
+ writeChatMessage(role, source, content, chatId) {
192
+ if (!this.redis)
193
+ return;
194
+ const msg = {
195
+ id: `${source}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
196
+ source,
197
+ role,
198
+ content,
199
+ timestamp: new Date().toISOString(),
200
+ chatId,
201
+ };
202
+ writeChatLog(this.redis, this.namespace, msg);
203
+ }
204
+ /** Returns the last chatId that sent a message — used by the chat bridge when no fixed chatId is configured. */
205
+ getLastActiveChatId() {
206
+ return this.lastActiveChatId;
207
+ }
208
+ /** Session key: "chatId:threadId" for topics, "chatId:main" for DMs/non-topic groups */
209
+ sessionKey(chatId, threadId) {
210
+ return `${chatId}:${threadId ?? 'main'}`;
211
+ }
212
+ /**
213
+ * Send a message back to the correct thread (or plain chat if no thread).
214
+ * When threadId is undefined, calls sendMessage with exactly 2 args to preserve
215
+ * backward-compatible call signatures (no extra options object).
216
+ */
217
+ replyToChat(chatId, text, threadId, opts) {
218
+ if (threadId !== undefined) {
219
+ return this.bot.sendMessage(chatId, text, { ...opts, message_thread_id: threadId });
220
+ }
221
+ if (opts) {
222
+ return this.bot.sendMessage(chatId, text, opts);
223
+ }
224
+ return this.bot.sendMessage(chatId, text);
225
+ }
226
+ /** Parse THREAD_CWD_MAP env var — maps thread name or thread_id to a CWD path */
227
+ getThreadCwdMap() {
228
+ const raw = process.env.THREAD_CWD_MAP;
229
+ if (!raw)
230
+ return {};
231
+ try {
232
+ return JSON.parse(raw);
233
+ }
234
+ catch {
235
+ console.warn('[cc-tg] THREAD_CWD_MAP is not valid JSON, ignoring');
236
+ return {};
237
+ }
238
+ }
200
239
  isAllowed(userId) {
201
240
  if (!this.opts.allowedUserIds?.length)
202
241
  return true;
@@ -205,10 +244,20 @@ export class CcTgBot {
205
244
  async handleTelegram(msg) {
206
245
  const chatId = msg.chat.id;
207
246
  const userId = msg.from?.id ?? chatId;
247
+ // Forum topic thread_id — undefined for DMs and non-topic group messages
248
+ const threadId = msg.message_thread_id;
249
+ // Thread name is available on the service message that creates a new topic.
250
+ // forum_topic_created is not in older @types/node-telegram-bot-api versions, so cast via unknown.
251
+ const rawMsg = msg;
252
+ const threadName = rawMsg.forum_topic_created
253
+ ? rawMsg.forum_topic_created.name
254
+ : undefined;
208
255
  if (!this.isAllowed(userId)) {
209
- await this.bot.sendMessage(chatId, "Not authorized.");
256
+ await this.replyToChat(chatId, "Not authorized.", threadId);
210
257
  return;
211
258
  }
259
+ // Track the last chat that sent us a message for the chat bridge
260
+ this.lastActiveChatId = chatId;
212
261
  // Group chat handling
213
262
  const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
214
263
  if (isGroup) {
@@ -227,17 +276,17 @@ export class CcTgBot {
227
276
  }
228
277
  // Voice message — transcribe then feed as text
229
278
  if (msg.voice || msg.audio) {
230
- await this.handleVoice(chatId, msg);
279
+ await this.handleVoice(chatId, msg, threadId, threadName);
231
280
  return;
232
281
  }
233
282
  // Photo — send as base64 image content block to Claude
234
283
  if (msg.photo?.length) {
235
- await this.handlePhoto(chatId, msg);
284
+ await this.handlePhoto(chatId, msg, threadId, threadName);
236
285
  return;
237
286
  }
238
287
  // Document — download to CWD/.cc-tg/uploads/, tell Claude the path
239
288
  if (msg.document) {
240
- await this.handleDocument(chatId, msg);
289
+ await this.handleDocument(chatId, msg, threadId, threadName);
241
290
  return;
242
291
  }
243
292
  let text = msg.text?.trim();
@@ -247,68 +296,69 @@ export class CcTgBot {
247
296
  if (this.botUsername) {
248
297
  text = text.replace(new RegExp(`@${this.botUsername}\\s*`, "g"), "").trim();
249
298
  }
299
+ const sessionKey = this.sessionKey(chatId, threadId);
250
300
  // /start or /reset — kill existing session and ack
251
301
  if (text === "/start" || text === "/reset") {
252
- this.killSession(chatId);
253
- await this.bot.sendMessage(chatId, "Session reset. Send a message to start.");
302
+ this.killSession(chatId, true, threadId);
303
+ await this.replyToChat(chatId, "Session reset. Send a message to start.", threadId);
254
304
  return;
255
305
  }
256
306
  // /stop — kill active session (interrupt running Claude task)
257
307
  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.");
308
+ const has = this.sessions.has(sessionKey);
309
+ this.killSession(chatId, true, threadId);
310
+ await this.replyToChat(chatId, has ? "Stopped." : "No active session.", threadId);
261
311
  return;
262
312
  }
263
313
  // /help — list all commands
264
314
  if (text === "/help") {
265
315
  const lines = BOT_COMMANDS.map((c) => `/${c.command} — ${c.description}`);
266
- await this.bot.sendMessage(chatId, lines.join("\n"));
316
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
267
317
  return;
268
318
  }
269
319
  // /status
270
320
  if (text === "/status") {
271
- const has = this.sessions.has(chatId);
321
+ const has = this.sessions.has(sessionKey);
272
322
  let status = has ? "Session active." : "No active session.";
273
323
  const sleeping = this.pendingRetries.size;
274
324
  if (sleeping > 0)
275
325
  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);
326
+ await this.replyToChat(chatId, status, threadId);
282
327
  return;
283
328
  }
284
329
  // /reload_mcp — kill cc-agent process so Claude Code auto-restarts it
285
330
  if (text === "/reload_mcp") {
286
- await this.handleReloadMcp(chatId);
331
+ await this.handleReloadMcp(chatId, threadId);
287
332
  return;
288
333
  }
289
334
  // /mcp_status — run `claude mcp list` and show connection status
290
335
  if (text === "/mcp_status") {
291
- await this.handleMcpStatus(chatId);
336
+ await this.handleMcpStatus(chatId, threadId);
292
337
  return;
293
338
  }
294
339
  // /mcp_version — show published npm version and cached npx entries
295
340
  if (text === "/mcp_version") {
296
- await this.handleMcpVersion(chatId);
341
+ await this.handleMcpVersion(chatId, threadId);
297
342
  return;
298
343
  }
299
344
  // /clear_npx_cache — wipe ~/.npm/_npx/ then restart cc-agent
300
345
  if (text === "/clear_npx_cache") {
301
- await this.handleClearNpxCache(chatId);
346
+ await this.handleClearNpxCache(chatId, threadId);
302
347
  return;
303
348
  }
304
349
  // /restart — restart the bot process in-place
305
350
  if (text === "/restart") {
306
- await this.handleRestart(chatId);
351
+ await this.handleRestart(chatId, threadId);
352
+ return;
353
+ }
354
+ // /cron <schedule> <prompt> | /cron list | /cron clear | /cron remove <id>
355
+ if (text.startsWith("/cron")) {
356
+ await this.handleCron(chatId, text, threadId);
307
357
  return;
308
358
  }
309
359
  // /get_file <path> — send a file from the server to the user
310
360
  if (text.startsWith("/get_file")) {
311
- await this.handleGetFile(chatId, text);
361
+ await this.handleGetFile(chatId, text, threadId);
312
362
  return;
313
363
  }
314
364
  // /cost — show session token usage and cost
@@ -324,85 +374,237 @@ export class CcTgBot {
324
374
  catch (err) {
325
375
  console.error("[cost] cc-agent cost_summary failed:", err.message);
326
376
  }
327
- await this.bot.sendMessage(chatId, reply);
377
+ await this.replyToChat(chatId, reply, threadId);
328
378
  return;
329
379
  }
330
- const session = this.getOrCreateSession(chatId);
380
+ // /skills list available Claude skills from ~/.claude/skills/
381
+ if (text === "/skills") {
382
+ await this.replyToChat(chatId, listSkills(), threadId);
383
+ return;
384
+ }
385
+ // /voice_retry — retry failed voice message transcriptions
386
+ if (text === "/voice_retry") {
387
+ await this.handleVoiceRetry(chatId, threadId);
388
+ return;
389
+ }
390
+ // /drivers — list available agent drivers via cc-agent MCP
391
+ if (text === "/drivers") {
392
+ await this.handleDrivers(chatId, threadId);
393
+ return;
394
+ }
395
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
331
396
  try {
332
- const prompt = buildPromptWithReplyContext(text, msg);
397
+ const enriched = await enrichPromptWithUrls(text);
398
+ const prompt = buildPromptWithReplyContext(enriched, msg);
333
399
  session.currentPrompt = prompt;
334
400
  session.claude.sendPrompt(prompt);
335
401
  this.startTyping(chatId, session);
402
+ this.writeChatMessage("user", "telegram", text, chatId);
336
403
  }
337
404
  catch (err) {
338
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
339
- this.killSession(chatId);
405
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
406
+ this.killSession(chatId, true, threadId);
340
407
  }
341
408
  }
342
- async handleVoice(chatId, msg) {
409
+ /**
410
+ * Feed a text message into the active Claude session for the given chat.
411
+ * Called by the notifier when a UI message arrives via Redis pub/sub.
412
+ */
413
+ async handleUserMessage(chatId, text) {
414
+ const session = this.getOrCreateSession(chatId);
415
+ try {
416
+ const enriched = await enrichPromptWithUrls(text);
417
+ session.currentPrompt = enriched;
418
+ session.claude.sendPrompt(enriched);
419
+ this.startTyping(chatId, session);
420
+ this.writeChatMessage("user", "ui", text, chatId);
421
+ }
422
+ catch (err) {
423
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`);
424
+ this.killSession(chatId, true);
425
+ }
426
+ }
427
+ async handleVoice(chatId, msg, threadId, threadName) {
343
428
  const fileId = msg.voice?.file_id ?? msg.audio?.file_id;
344
429
  if (!fileId)
345
430
  return;
346
431
  console.log(`[voice:${chatId}] received voice message, transcribing...`);
347
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
432
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
433
+ // Store in Redis before transcription so we can retry on failure
434
+ const pendingEntry = JSON.stringify({
435
+ file_id: fileId,
436
+ chat_id: chatId,
437
+ message_id: msg.message_id,
438
+ timestamp: Date.now(),
439
+ });
440
+ if (this.redis) {
441
+ await this.redis.rpush("voice:pending", pendingEntry).catch((err) => console.warn("[voice] redis rpush voice:pending failed:", err.message));
442
+ }
348
443
  try {
349
- const transcript = await withRetry(async () => {
350
- const fileLink = await this.bot.getFileLink(fileId);
351
- return transcribeVoice(fileLink);
352
- }, 3, [2000, 5000]);
444
+ const fileLink = await this.bot.getFileLink(fileId);
445
+ const transcript = await transcribeVoice(fileLink);
353
446
  console.log(`[voice:${chatId}] transcribed: ${transcript}`);
447
+ // Remove from pending on success
448
+ if (this.redis) {
449
+ await this.redis.lrem("voice:pending", 0, pendingEntry).catch((err) => console.warn("[voice] redis lrem voice:pending failed:", err.message));
450
+ }
354
451
  if (!transcript || transcript === "[empty transcription]") {
355
- await this.bot.sendMessage(chatId, "Could not transcribe voice message.");
452
+ await this.replyToChat(chatId, "Could not transcribe voice message.", threadId);
356
453
  return;
357
454
  }
358
455
  // Feed transcript into Claude as if user typed it
359
- const session = this.getOrCreateSession(chatId);
456
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
360
457
  try {
361
458
  const prompt = buildPromptWithReplyContext(transcript, msg);
459
+ this.writeChatMessage("user", "telegram", transcript, chatId);
362
460
  session.currentPrompt = prompt;
363
461
  session.claude.sendPrompt(prompt);
364
462
  this.startTyping(chatId, session);
365
463
  }
366
464
  catch (err) {
367
- await this.bot.sendMessage(chatId, `Error sending to Claude: ${err.message}`);
368
- this.killSession(chatId);
465
+ await this.replyToChat(chatId, `Error sending to Claude: ${err.message}`, threadId);
466
+ this.killSession(chatId, true, threadId);
369
467
  }
370
468
  }
371
469
  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}`);
470
+ const errMsg = err.message;
471
+ console.error(`[voice:${chatId}] error:`, errMsg);
472
+ // Push to voice:failed on failure (entry stays in voice:pending for retry)
473
+ if (this.redis) {
474
+ const failedEntry = JSON.stringify({
475
+ file_id: fileId,
476
+ chat_id: chatId,
477
+ message_id: msg.message_id,
478
+ timestamp: Date.now(),
479
+ error: errMsg,
480
+ failed_at: Date.now(),
481
+ });
482
+ this.redis.rpush("voice:failed", failedEntry)
483
+ .then(() => this.redis.expire("voice:failed", 48 * 60 * 60))
484
+ .catch((redisErr) => console.warn("[voice] redis write voice:failed failed:", redisErr.message));
485
+ }
486
+ // User-friendly error messages
487
+ let userMsg;
488
+ if (errMsg.includes("whisper-cpp not found") || errMsg.includes("whisper not found")) {
489
+ userMsg = "Voice transcription unavailable — whisper-cpp not installed";
490
+ }
491
+ else if (errMsg.includes("No whisper model found")) {
492
+ userMsg = "Voice transcription unavailable — no whisper model found";
493
+ }
494
+ else if (errMsg.includes("HTTP") && errMsg.includes("downloading")) {
495
+ userMsg = "Could not download voice file from Telegram";
496
+ }
497
+ else {
498
+ userMsg = `Voice transcription failed: ${errMsg}`;
499
+ }
500
+ await this.replyToChat(chatId, userMsg, threadId);
378
501
  }
379
502
  }
380
- async handlePhoto(chatId, msg) {
503
+ async handleVoiceRetry(chatId, threadId) {
504
+ if (!this.redis) {
505
+ await this.replyToChat(chatId, "Redis not configured — voice retry unavailable.", threadId);
506
+ return;
507
+ }
508
+ const [pendingRaw, failedRaw] = await Promise.all([
509
+ this.redis.lrange("voice:pending", 0, -1).catch(() => []),
510
+ this.redis.lrange("voice:failed", 0, -1).catch(() => []),
511
+ ]);
512
+ // Deduplicate by file_id across both lists
513
+ const allEntries = new Map();
514
+ for (const raw of [...pendingRaw, ...failedRaw]) {
515
+ try {
516
+ const entry = JSON.parse(raw);
517
+ if (entry.file_id)
518
+ allEntries.set(entry.file_id, entry);
519
+ }
520
+ catch { /* skip malformed entries */ }
521
+ }
522
+ if (allEntries.size === 0) {
523
+ await this.replyToChat(chatId, "No pending voice messages to retry.", threadId);
524
+ return;
525
+ }
526
+ await this.replyToChat(chatId, `Retrying ${allEntries.size} voice message(s)...`, threadId);
527
+ let succeeded = 0;
528
+ let failed = 0;
529
+ const errors = [];
530
+ for (const [fileId, entry] of allEntries) {
531
+ try {
532
+ const fileLink = await this.bot.getFileLink(fileId);
533
+ const transcript = await transcribeVoice(fileLink);
534
+ if (transcript && transcript !== "[empty transcription]") {
535
+ const session = this.getOrCreateSession(entry.chat_id, threadId, undefined);
536
+ session.claude.sendPrompt(transcript);
537
+ this.writeChatMessage("user", "telegram", transcript, entry.chat_id);
538
+ // Remove from both lists
539
+ const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
540
+ const matchFailed = failedRaw.find((r) => r.includes(`"${fileId}"`));
541
+ if (matchPending)
542
+ await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
543
+ if (matchFailed)
544
+ await this.redis.lrem("voice:failed", 0, matchFailed).catch(() => { });
545
+ succeeded++;
546
+ }
547
+ else {
548
+ failed++;
549
+ errors.push(`${fileId}: empty transcription`);
550
+ }
551
+ }
552
+ catch (err) {
553
+ const errMsg = err.message;
554
+ failed++;
555
+ errors.push(`${fileId}: ${errMsg}`);
556
+ // Permanently unretryable (expired Telegram link) — remove from voice:pending
557
+ if (errMsg.includes("Bad Request") || errMsg.includes("file_id")) {
558
+ const matchPending = pendingRaw.find((r) => r.includes(`"${fileId}"`));
559
+ if (matchPending)
560
+ await this.redis.lrem("voice:pending", 0, matchPending).catch(() => { });
561
+ }
562
+ }
563
+ }
564
+ // Purge stale entries from voice:pending older than 48h
565
+ const staleThreshold = 48 * 60 * 60 * 1000;
566
+ let purged = 0;
567
+ for (const raw of pendingRaw) {
568
+ try {
569
+ const entry = JSON.parse(raw);
570
+ if (entry.timestamp && Date.now() - entry.timestamp > staleThreshold) {
571
+ await this.redis.lrem("voice:pending", 0, raw).catch(() => { });
572
+ purged++;
573
+ }
574
+ }
575
+ catch { /* skip malformed entries */ }
576
+ }
577
+ const lines = [`Voice retry complete: ${succeeded} succeeded, ${failed} failed, ${purged} stale entries purged.`];
578
+ if (errors.length > 0)
579
+ lines.push(...errors.map((e) => `• ${e}`));
580
+ await this.replyToChat(chatId, lines.join("\n"), threadId);
581
+ }
582
+ async handlePhoto(chatId, msg, threadId, threadName) {
381
583
  // Pick highest resolution photo
382
584
  const photos = msg.photo;
383
585
  const best = photos[photos.length - 1];
384
586
  const caption = msg.caption?.trim();
385
587
  console.log(`[photo:${chatId}] received image file_id=${best.file_id}`);
386
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
588
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
387
589
  try {
388
590
  const fileLink = await this.bot.getFileLink(best.file_id);
389
591
  const imageData = await fetchAsBase64(fileLink);
390
592
  // Telegram photos are always JPEG
391
- const session = this.getOrCreateSession(chatId);
593
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
392
594
  session.claude.sendImage(imageData, "image/jpeg", caption);
393
595
  this.startTyping(chatId, session);
394
596
  }
395
597
  catch (err) {
396
598
  console.error(`[photo:${chatId}] error:`, err.message);
397
- await this.bot.sendMessage(chatId, `Failed to process image: ${err.message}`);
599
+ await this.replyToChat(chatId, `Failed to process image: ${err.message}`, threadId);
398
600
  }
399
601
  }
400
- async handleDocument(chatId, msg) {
602
+ async handleDocument(chatId, msg, threadId, threadName) {
401
603
  const doc = msg.document;
402
604
  const caption = msg.caption?.trim();
403
605
  const fileName = doc.file_name ?? `file_${doc.file_id}`;
404
606
  console.log(`[doc:${chatId}] received document file_name=${fileName} mime=${doc.mime_type}`);
405
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
607
+ this.bot.sendChatAction(chatId, "typing", threadId !== undefined ? { message_thread_id: threadId } : undefined).catch(() => { });
406
608
  try {
407
609
  const uploadsDir = join(this.opts.cwd ?? process.cwd(), ".cc-tg", "uploads");
408
610
  mkdirSync(uploadsDir, { recursive: true });
@@ -413,22 +615,34 @@ export class CcTgBot {
413
615
  const prompt = caption
414
616
  ? `${caption}\n\nATTACHMENTS: [${fileName}](${destPath})`
415
617
  : `ATTACHMENTS: [${fileName}](${destPath})`;
416
- const session = this.getOrCreateSession(chatId);
618
+ const session = this.getOrCreateSession(chatId, threadId, threadName);
417
619
  session.claude.sendPrompt(prompt);
418
620
  this.startTyping(chatId, session);
419
621
  }
420
622
  catch (err) {
421
623
  console.error(`[doc:${chatId}] error:`, err.message);
422
- await this.bot.sendMessage(chatId, `Failed to receive document: ${err.message}`);
624
+ await this.replyToChat(chatId, `Failed to receive document: ${err.message}`, threadId);
423
625
  }
424
626
  }
425
- getOrCreateSession(chatId) {
426
- const existing = this.sessions.get(chatId);
627
+ getOrCreateSession(chatId, threadId, threadName) {
628
+ const key = this.sessionKey(chatId, threadId);
629
+ const existing = this.sessions.get(key);
427
630
  if (existing && !existing.claude.exited)
428
631
  return existing;
632
+ // Determine CWD for this thread — check THREAD_CWD_MAP by name then by ID
633
+ let sessionCwd = this.opts.cwd;
634
+ const threadCwdMap = this.getThreadCwdMap();
635
+ if (threadName && threadCwdMap[threadName]) {
636
+ sessionCwd = threadCwdMap[threadName];
637
+ console.log(`[cc-tg] thread "${threadName}" → cwd: ${sessionCwd}`);
638
+ }
639
+ else if (threadId !== undefined && threadCwdMap[String(threadId)]) {
640
+ sessionCwd = threadCwdMap[String(threadId)];
641
+ console.log(`[cc-tg] thread ${threadId} → cwd: ${sessionCwd}`);
642
+ }
429
643
  const claude = new ClaudeProcess({
430
- cwd: this.opts.cwd,
431
- token: this.opts.claudeToken,
644
+ cwd: sessionCwd,
645
+ token: getCurrentToken() || this.opts.claudeToken,
432
646
  });
433
647
  const session = {
434
648
  claude,
@@ -438,6 +652,7 @@ export class CcTgBot {
438
652
  writtenFiles: new Set(),
439
653
  currentPrompt: "",
440
654
  isRetry: false,
655
+ threadId,
441
656
  };
442
657
  claude.on("usage", (usage) => {
443
658
  this.costStore.addUsage(chatId, usage);
@@ -446,33 +661,47 @@ export class CcTgBot {
446
661
  // Verbose logging — log every message type and subtype
447
662
  const subtype = msg.payload.subtype ?? "";
448
663
  const toolName = this.extractToolName(msg);
449
- const logParts = [`[claude:${chatId}] msg=${msg.type}`];
664
+ const logParts = [`[claude:${key}] msg=${msg.type}`];
450
665
  if (subtype)
451
666
  logParts.push(`subtype=${subtype}`);
452
667
  if (toolName)
453
668
  logParts.push(`tool=${toolName}`);
454
669
  console.log(logParts.join(" "));
455
670
  // Track files written by Write/Edit tool calls
456
- this.trackWrittenFiles(msg, session, this.opts.cwd);
671
+ this.trackWrittenFiles(msg, session, sessionCwd);
672
+ // Publish tool call events to the chat log
673
+ if (msg.type === "assistant") {
674
+ const message = msg.payload.message;
675
+ const content = message?.content;
676
+ if (Array.isArray(content)) {
677
+ for (const block of content) {
678
+ if (block.type !== "tool_use")
679
+ continue;
680
+ const name = block.name;
681
+ const input = block.input;
682
+ this.writeChatMessage("tool", "cc-tg", `[tool] ${name}: ${JSON.stringify(input ?? {})}`, chatId);
683
+ }
684
+ }
685
+ }
457
686
  this.handleClaudeMessage(chatId, session, msg);
458
687
  });
459
688
  claude.on("stderr", (data) => {
460
689
  const line = data.trim();
461
690
  if (line)
462
- console.error(`[claude:${chatId}:stderr]`, line);
691
+ console.error(`[claude:${key}:stderr]`, line);
463
692
  });
464
693
  claude.on("exit", (code) => {
465
- console.log(`[claude:${chatId}] exited code=${code}`);
694
+ console.log(`[claude:${key}] exited code=${code}`);
466
695
  this.stopTyping(session);
467
- this.sessions.delete(chatId);
696
+ this.sessions.delete(key);
468
697
  });
469
698
  claude.on("error", (err) => {
470
- console.error(`[claude:${chatId}] process error: ${err.message}`);
699
+ console.error(`[claude:${key}] process error: ${err.message}`);
471
700
  this.bot.sendMessage(chatId, `Claude process error: ${err.message}`).catch(() => { });
472
701
  this.stopTyping(session);
473
- this.sessions.delete(chatId);
702
+ this.sessions.delete(key);
474
703
  });
475
- this.sessions.set(chatId, session);
704
+ this.sessions.set(key, session);
476
705
  return session;
477
706
  }
478
707
  handleClaudeMessage(chatId, session, msg) {
@@ -488,33 +717,58 @@ export class CcTgBot {
488
717
  // Check for usage/rate limit signals before forwarding to Telegram
489
718
  const sig = detectUsageLimit(text);
490
719
  if (sig.detected) {
720
+ const threadId = session.threadId;
721
+ const retryKey = this.sessionKey(chatId, threadId);
491
722
  const lastPrompt = session.currentPrompt;
492
- const prevRetry = this.pendingRetries.get(chatId);
723
+ const prevRetry = this.pendingRetries.get(retryKey);
493
724
  const attempt = (prevRetry?.attempt ?? 0) + 1;
494
725
  if (prevRetry)
495
726
  clearTimeout(prevRetry.timer);
496
- this.bot.sendMessage(chatId, sig.humanMessage).catch(() => { });
497
- this.killSession(chatId);
727
+ this.replyToChat(chatId, sig.humanMessage, threadId).catch(() => { });
728
+ this.killSession(chatId, true, threadId);
729
+ // Token rotation: if this is a usage_exhausted signal and we have multiple
730
+ // tokens, rotate to the next one and retry immediately instead of sleeping.
731
+ // Only rotate if we haven't yet cycled through all tokens (attempt <= count-1).
732
+ if (sig.reason === "usage_exhausted" && getTokenCount() > 1 && attempt <= getTokenCount() - 1) {
733
+ const prevIdx = getTokenIndex();
734
+ rotateToken();
735
+ const newIdx = getTokenIndex();
736
+ const total = getTokenCount();
737
+ console.log(`[cc-tg] Token ${prevIdx + 1}/${total} exhausted, rotating to token ${newIdx + 1}/${total}`);
738
+ this.replyToChat(chatId, `🔄 Token ${prevIdx + 1}/${total} exhausted, switching to token ${newIdx + 1}/${total}...`, threadId).catch(() => { });
739
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer: setTimeout(() => { }, 0) });
740
+ try {
741
+ const retrySession = this.getOrCreateSession(chatId, threadId);
742
+ retrySession.currentPrompt = lastPrompt;
743
+ retrySession.isRetry = true;
744
+ retrySession.claude.sendPrompt(lastPrompt);
745
+ this.startTyping(chatId, retrySession);
746
+ }
747
+ catch (err) {
748
+ this.replyToChat(chatId, `❌ Failed to retry with rotated token: ${err.message}`, threadId).catch(() => { });
749
+ }
750
+ return;
751
+ }
498
752
  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);
753
+ this.replyToChat(chatId, "❌ Claude usage limit persists after 3 retries. Please try again later.", threadId).catch(() => { });
754
+ this.pendingRetries.delete(retryKey);
501
755
  return;
502
756
  }
503
- console.log(`[usage-limit:${chatId}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
757
+ console.log(`[usage-limit:${retryKey}] ${sig.reason} — scheduling retry attempt=${attempt} in ${sig.retryAfterMs}ms`);
504
758
  const timer = setTimeout(() => {
505
- this.pendingRetries.delete(chatId);
759
+ this.pendingRetries.delete(retryKey);
506
760
  try {
507
- const retrySession = this.getOrCreateSession(chatId);
761
+ const retrySession = this.getOrCreateSession(chatId, threadId);
508
762
  retrySession.currentPrompt = lastPrompt;
509
763
  retrySession.isRetry = true;
510
764
  retrySession.claude.sendPrompt(lastPrompt);
511
765
  this.startTyping(chatId, retrySession);
512
766
  }
513
767
  catch (err) {
514
- this.bot.sendMessage(chatId, `❌ Failed to retry: ${err.message}`).catch(() => { });
768
+ this.replyToChat(chatId, `❌ Failed to retry: ${err.message}`, threadId).catch(() => { });
515
769
  }
516
770
  }, sig.retryAfterMs);
517
- this.pendingRetries.set(chatId, { text: lastPrompt, attempt, timer });
771
+ this.pendingRetries.set(retryKey, { text: lastPrompt, attempt, timer });
518
772
  return;
519
773
  }
520
774
  // Accumulate text and debounce — Claude streams chunks rapidly
@@ -526,9 +780,11 @@ export class CcTgBot {
526
780
  startTyping(chatId, session) {
527
781
  this.stopTyping(session);
528
782
  // Send immediately, then keep alive every 4s
529
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
783
+ // Pass message_thread_id so typing appears in the correct forum topic thread
784
+ const threadOpts = session.threadId !== undefined ? { message_thread_id: session.threadId } : undefined;
785
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
530
786
  session.typingTimer = setInterval(() => {
531
- this.bot.sendChatAction(chatId, "typing").catch(() => { });
787
+ this.bot.sendChatAction(chatId, "typing", threadOpts).catch(() => { });
532
788
  }, TYPING_INTERVAL_MS);
533
789
  }
534
790
  stopTyping(session) {
@@ -543,15 +799,17 @@ export class CcTgBot {
543
799
  session.flushTimer = null;
544
800
  if (!raw)
545
801
  return;
802
+ this.writeChatMessage("assistant", "cc-tg", raw, chatId);
546
803
  const text = session.isRetry ? `✅ Claude is back!\n\n${raw}` : raw;
547
804
  session.isRetry = false;
548
- // Format for Telegram MarkdownV2 and split if needed (max 4096 chars)
805
+ // Format for Telegram HTML and split if needed (max 4096 chars)
549
806
  const formatted = formatForTelegram(text);
550
807
  const chunks = splitLongMessage(formatted);
808
+ const threadId = session.threadId;
551
809
  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));
810
+ this.replyToChat(chatId, chunk, threadId, { parse_mode: "HTML" }).catch(() => {
811
+ // HTML parse failed — retry as plain text
812
+ this.replyToChat(chatId, chunk, threadId).catch((err) => console.error(`[tg:${chatId}] send failed:`, err.message));
555
813
  });
556
814
  }
557
815
  // Hybrid file upload: find files mentioned in result text that Claude actually wrote
@@ -724,11 +982,12 @@ export class CcTgBot {
724
982
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
725
983
  if (fileSize > MAX_TG_FILE_BYTES) {
726
984
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
727
- this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`).catch(() => { });
985
+ this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, session.threadId).catch(() => { });
728
986
  continue;
729
987
  }
730
988
  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));
989
+ const docOpts = session.threadId ? { message_thread_id: session.threadId } : undefined;
990
+ this.bot.sendDocument(chatId, filePath, docOpts).catch((err) => console.error(`[tg:${chatId}] sendDocument failed for ${filePath}:`, err.message));
732
991
  }
733
992
  // Clear written files for next turn
734
993
  session.writtenFiles.clear();
@@ -743,203 +1002,6 @@ export class CcTgBot {
743
1002
  const toolUse = content.find((b) => b.type === "tool_use");
744
1003
  return toolUse?.name ?? "";
745
1004
  }
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
1005
  /** Find cc-agent PIDs via pgrep. Returns array of numeric PIDs. */
944
1006
  findCcAgentPids() {
945
1007
  try {
@@ -965,34 +1027,33 @@ export class CcTgBot {
965
1027
  }
966
1028
  return pids;
967
1029
  }
968
- async handleReloadMcp(chatId) {
969
- await this.bot.sendMessage(chatId, "Clearing npx cache and reloading MCP...");
1030
+ async handleReloadMcp(chatId, threadId) {
1031
+ await this.replyToChat(chatId, "Clearing npx cache and reloading MCP...", threadId);
970
1032
  try {
971
1033
  const home = process.env.HOME ?? "~";
972
- const npmBase = process.env.npm_config_cache ? join(process.env.npm_config_cache, "..") : `${home}/.npm`;
973
- execSync(`rm -rf "${npmBase}/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
974
- console.log(`[mcp] cleared ${npmBase}/_npx/`);
1034
+ execSync(`rm -rf "${home}/.npm/_npx/"`, { encoding: "utf8", shell: "/bin/sh" });
1035
+ console.log("[mcp] cleared ~/.npm/_npx/");
975
1036
  }
976
1037
  catch (err) {
977
- await this.bot.sendMessage(chatId, `Warning: failed to clear npx cache: ${err.message}`);
1038
+ await this.replyToChat(chatId, `Warning: failed to clear npx cache: ${err.message}`, threadId);
978
1039
  }
979
1040
  const pids = this.killCcAgent();
980
1041
  if (pids.length === 0) {
981
- await this.bot.sendMessage(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.");
1042
+ await this.replyToChat(chatId, "NPX cache cleared. No cc-agent process found — MCP will start fresh on the next agent call.", threadId);
982
1043
  return;
983
1044
  }
984
- await this.bot.sendMessage(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.`);
1045
+ 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);
985
1046
  }
986
- async handleMcpStatus(chatId) {
1047
+ async handleMcpStatus(chatId, threadId) {
987
1048
  try {
988
1049
  const output = execSync("claude mcp list", { encoding: "utf8", shell: "/bin/sh" }).trim();
989
- await this.bot.sendMessage(chatId, `MCP server status:\n\n${output || "(no output)"}`);
1050
+ await this.replyToChat(chatId, `MCP server status:\n\n${output || "(no output)"}`, threadId);
990
1051
  }
991
1052
  catch (err) {
992
- await this.bot.sendMessage(chatId, `Failed to run claude mcp list: ${err.message}`);
1053
+ await this.replyToChat(chatId, `Failed to run claude mcp list: ${err.message}`, threadId);
993
1054
  }
994
1055
  }
995
- async handleMcpVersion(chatId) {
1056
+ async handleMcpVersion(chatId, threadId) {
996
1057
  let npmVersion = "unknown";
997
1058
  let cacheEntries = "(unavailable)";
998
1059
  try {
@@ -1009,18 +1070,14 @@ export class CcTgBot {
1009
1070
  catch {
1010
1071
  cacheEntries = "(empty or not found)";
1011
1072
  }
1012
- await this.bot.sendMessage(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`);
1073
+ await this.replyToChat(chatId, `cc-agent npm version: ${npmVersion}\n\nnpx cache (~/.npm/_npx/):\n${cacheEntries}`, threadId);
1013
1074
  }
1014
- async handleClearNpxCache(chatId) {
1075
+ async handleClearNpxCache(chatId, threadId) {
1015
1076
  const home = process.env.HOME ?? "/tmp";
1016
- // Use the isolated npm cache dir set in the plist (npm_config_cache), not hardcoded ~/.npm
1017
- const npmBase = process.env.npm_config_cache
1018
- ? join(process.env.npm_config_cache, "..")
1019
- : `${home}/.npm`;
1020
1077
  const cleared = [];
1021
1078
  const failed = [];
1022
1079
  // Clear both npx execution cache and full npm package cache
1023
- for (const dir of [`${npmBase}/_npx`, `${npmBase}/cache`]) {
1080
+ for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
1024
1081
  try {
1025
1082
  execSync(`rm -rf "${dir}"`, { encoding: "utf8", shell: "/bin/sh" });
1026
1083
  cleared.push(dir.replace(home, "~"));
@@ -1038,85 +1095,169 @@ export class CcTgBot {
1038
1095
  const clearNote = failed.length
1039
1096
  ? `Cleared: ${cleared.join(", ")}. Failed: ${failed.join(", ")}.`
1040
1097
  : `Cleared: ${cleared.join(", ")}.`;
1041
- await this.bot.sendMessage(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`);
1098
+ await this.replyToChat(chatId, `${clearNote}${pidNote} Next call picks up latest npm version.`, threadId);
1042
1099
  }
1043
- async handleRestart(chatId) {
1044
- await this.bot.sendMessage(chatId, "Clearing cache and restarting... brb.");
1100
+ async handleRestart(chatId, threadId) {
1101
+ await this.replyToChat(chatId, "Clearing cache and restarting... brb.", threadId);
1045
1102
  await new Promise(resolve => setTimeout(resolve, 300));
1046
1103
  // Clear npm caches before restart so launchd brings up fresh version
1047
- // Use isolated npm_config_cache path from plist, not hardcoded ~/.npm
1048
1104
  const home = process.env.HOME ?? "/tmp";
1049
- const npmBase = process.env.npm_config_cache
1050
- ? join(process.env.npm_config_cache, "..")
1051
- : `${home}/.npm`;
1052
- for (const dir of [`${npmBase}/_npx`, `${npmBase}/cache`]) {
1105
+ for (const dir of [`${home}/.npm/_npx`, `${home}/.npm/cache`]) {
1053
1106
  try {
1054
1107
  execSync(`rm -rf "${dir}"`, { shell: "/bin/sh" });
1055
1108
  }
1056
1109
  catch { }
1057
1110
  }
1058
1111
  // Kill all active Claude sessions cleanly
1059
- for (const [cid] of this.sessions) {
1060
- this.killSession(cid);
1112
+ for (const session of this.sessions.values()) {
1113
+ this.stopTyping(session);
1114
+ session.claude.kill();
1061
1115
  }
1116
+ this.sessions.clear();
1062
1117
  await new Promise(resolve => setTimeout(resolve, 200));
1063
1118
  process.exit(0);
1064
1119
  }
1065
- async handleGetFile(chatId, text) {
1120
+ async handleCron(chatId, text, threadId) {
1121
+ const args = text.slice("/cron".length).trim();
1122
+ if (args === "list" || args === "") {
1123
+ const jobs = this.cron.list(chatId);
1124
+ if (!jobs.length) {
1125
+ await this.replyToChat(chatId, "No cron jobs.", threadId);
1126
+ return;
1127
+ }
1128
+ const lines = jobs.map((j, i) => {
1129
+ const short = j.prompt.length > 50 ? j.prompt.slice(0, 50) + "…" : j.prompt;
1130
+ return `#${i + 1} ${j.schedule} — "${short}"`;
1131
+ });
1132
+ await this.replyToChat(chatId, `Cron jobs (${jobs.length}):\n${lines.join("\n")}`, threadId);
1133
+ return;
1134
+ }
1135
+ if (args === "clear") {
1136
+ const n = this.cron.clearAll(chatId);
1137
+ await this.replyToChat(chatId, `Cleared ${n} cron job(s).`, threadId);
1138
+ return;
1139
+ }
1140
+ if (args.startsWith("remove ")) {
1141
+ const id = args.slice("remove ".length).trim();
1142
+ const ok = this.cron.remove(chatId, id);
1143
+ await this.replyToChat(chatId, ok ? `Removed ${id}.` : `Not found: ${id}`, threadId);
1144
+ return;
1145
+ }
1146
+ const scheduleMatch = args.match(/^(every\s+\d+[mhd])\s+(.+)$/i);
1147
+ if (!scheduleMatch) {
1148
+ await this.replyToChat(chatId, "Usage:\n/cron every 1h <prompt>\n/cron list\n/cron remove <id>\n/cron clear", threadId);
1149
+ return;
1150
+ }
1151
+ const schedule = scheduleMatch[1];
1152
+ const prompt = scheduleMatch[2];
1153
+ const job = this.cron.add(chatId, schedule, prompt);
1154
+ if (!job) {
1155
+ await this.replyToChat(chatId, "Invalid schedule. Use: every 30m / every 2h / every 1d", threadId);
1156
+ return;
1157
+ }
1158
+ await this.replyToChat(chatId, `Cron set [${job.id}]: ${schedule} — "${prompt}"`, threadId);
1159
+ }
1160
+ runCronTask(chatId, prompt, done = () => { }) {
1161
+ const cronProcess = new ClaudeProcess({ cwd: this.opts.cwd ?? process.cwd() });
1162
+ cronProcess.sendPrompt(prompt);
1163
+ cronProcess.on("message", (msg) => {
1164
+ const result = extractText(msg);
1165
+ if (result) {
1166
+ const formatted = formatForTelegram(`🕐 ${result}`);
1167
+ const chunks = splitLongMessage(formatted);
1168
+ for (const chunk of chunks) {
1169
+ this.replyToChat(chatId, chunk).catch((err) => console.error("[cron] send failed:", err.message));
1170
+ }
1171
+ }
1172
+ });
1173
+ cronProcess.on("exit", () => done());
1174
+ }
1175
+ async handleGetFile(chatId, text, threadId) {
1066
1176
  const arg = text.slice("/get_file".length).trim();
1067
1177
  if (!arg) {
1068
- await this.bot.sendMessage(chatId, "Usage: /get_file <path>");
1178
+ await this.replyToChat(chatId, "Usage: /get_file <path>", threadId);
1069
1179
  return;
1070
1180
  }
1071
1181
  const filePath = resolve(arg);
1072
1182
  const safeDirs = ["/tmp/", "/var/folders/", os.homedir() + "/Downloads/", this.opts.cwd ?? process.cwd()];
1073
1183
  const inSafeDir = safeDirs.some(d => filePath.startsWith(d));
1074
1184
  if (!inSafeDir) {
1075
- await this.bot.sendMessage(chatId, "Access denied: path not in allowed directories");
1185
+ await this.replyToChat(chatId, "Access denied: path not in allowed directories", threadId);
1076
1186
  return;
1077
1187
  }
1078
1188
  if (!existsSync(filePath)) {
1079
- await this.bot.sendMessage(chatId, `File not found: ${filePath}`);
1189
+ await this.replyToChat(chatId, `File not found: ${filePath}`, threadId);
1080
1190
  return;
1081
1191
  }
1082
1192
  if (!statSync(filePath).isFile()) {
1083
- await this.bot.sendMessage(chatId, `Not a file: ${filePath}`);
1193
+ await this.replyToChat(chatId, `Not a file: ${filePath}`, threadId);
1084
1194
  return;
1085
1195
  }
1086
1196
  if (this.isSensitiveFile(filePath)) {
1087
- await this.bot.sendMessage(chatId, "Access denied: sensitive file");
1197
+ await this.replyToChat(chatId, "Access denied: sensitive file", threadId);
1088
1198
  return;
1089
1199
  }
1090
1200
  const MAX_TG_FILE_BYTES = 50 * 1024 * 1024;
1091
1201
  const fileSize = statSync(filePath).size;
1092
1202
  if (fileSize > MAX_TG_FILE_BYTES) {
1093
1203
  const mb = (fileSize / (1024 * 1024)).toFixed(1);
1094
- await this.bot.sendMessage(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`);
1204
+ await this.replyToChat(chatId, `File too large for Telegram (${mb}mb). Find it at: ${filePath}`, threadId);
1095
1205
  return;
1096
1206
  }
1097
- await this.bot.sendDocument(chatId, filePath);
1207
+ const docOpts = threadId ? { message_thread_id: threadId } : undefined;
1208
+ await this.bot.sendDocument(chatId, filePath, docOpts);
1209
+ }
1210
+ async handleDrivers(chatId, threadId) {
1211
+ try {
1212
+ const raw = await this.callCcAgentTool("list_drivers");
1213
+ if (!raw) {
1214
+ await this.replyToChat(chatId, "No drivers available or cc-agent did not respond.", threadId);
1215
+ return;
1216
+ }
1217
+ // Try to pretty-print JSON array/object, fall back to raw string
1218
+ let reply;
1219
+ try {
1220
+ const data = JSON.parse(raw);
1221
+ if (Array.isArray(data)) {
1222
+ const current = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
1223
+ const lines = data.map((d) => d === current ? `• ${d} (default)` : `• ${d}`);
1224
+ reply = `Available drivers:\n${lines.join("\n")}`;
1225
+ }
1226
+ else {
1227
+ reply = `Available drivers:\n${raw}`;
1228
+ }
1229
+ }
1230
+ catch {
1231
+ reply = `Available drivers:\n${raw}`;
1232
+ }
1233
+ await this.replyToChat(chatId, reply, threadId);
1234
+ }
1235
+ catch (err) {
1236
+ await this.replyToChat(chatId, `Failed to list drivers: ${err.message}`, threadId);
1237
+ }
1098
1238
  }
1099
1239
  callCcAgentTool(toolName, args = {}) {
1240
+ // For spawn tools, pass through the configured driver and model
1241
+ const spawnTools = new Set(["spawn_agent", "spawn_from_profile"]);
1242
+ if (spawnTools.has(toolName)) {
1243
+ const driver = process.env.CC_AGENT_DEFAULT_DRIVER || "claude";
1244
+ const model = process.env.CC_AGENT_DEFAULT_MODEL || undefined;
1245
+ args = { agent_driver: driver, ...(model ? { agent_model: model } : {}), ...args };
1246
+ }
1100
1247
  return new Promise((resolve) => {
1101
1248
  let settled = false;
1102
- let procRef = null;
1103
1249
  const done = (val) => {
1104
1250
  if (!settled) {
1105
1251
  settled = true;
1106
- try {
1107
- procRef?.kill();
1108
- }
1109
- catch { }
1110
1252
  resolve(val);
1111
1253
  }
1112
1254
  };
1113
1255
  let proc;
1114
1256
  try {
1115
- proc = spawn("npx", ["--prefer-online", "-y", "@gonzih/cc-agent@latest"], {
1257
+ proc = spawn("npx", ["-y", "@gonzih/cc-agent@latest"], {
1116
1258
  env: { ...process.env },
1117
1259
  stdio: ["pipe", "pipe", "pipe"],
1118
1260
  });
1119
- procRef = proc;
1120
1261
  }
1121
1262
  catch (err) {
1122
1263
  console.error("[mcp] failed to spawn cc-agent:", err.message);
@@ -1173,21 +1314,25 @@ export class CcTgBot {
1173
1314
  proc.on("exit", () => { clearTimeout(timeout); done(null); });
1174
1315
  });
1175
1316
  }
1176
- killSession(chatId, keepCrons = true) {
1177
- const session = this.sessions.get(chatId);
1317
+ killSession(chatId, _keepCrons = true, threadId) {
1318
+ const key = this.sessionKey(chatId, threadId);
1319
+ const session = this.sessions.get(key);
1178
1320
  if (session) {
1179
1321
  this.stopTyping(session);
1180
1322
  session.claude.kill();
1181
- this.sessions.delete(chatId);
1323
+ this.sessions.delete(key);
1182
1324
  }
1183
- if (!keepCrons)
1184
- this.cron.clearAll(chatId);
1325
+ }
1326
+ getMe() {
1327
+ return this.bot.getMe();
1185
1328
  }
1186
1329
  stop() {
1187
1330
  this.bot.stopPolling();
1188
- for (const [chatId] of this.sessions) {
1189
- this.killSession(chatId);
1331
+ for (const session of this.sessions.values()) {
1332
+ this.stopTyping(session);
1333
+ session.claude.kill();
1190
1334
  }
1335
+ this.sessions.clear();
1191
1336
  }
1192
1337
  }
1193
1338
  function buildPromptWithReplyContext(text, msg) {
@@ -1226,6 +1371,85 @@ function downloadToFile(url, destPath) {
1226
1371
  }).on("error", reject);
1227
1372
  });
1228
1373
  }
1374
+ /** Fetch URL via Jina Reader and return first maxChars characters */
1375
+ function fetchUrlViaJina(url, maxChars = 2000) {
1376
+ const jinaUrl = `https://r.jina.ai/${url}`;
1377
+ return new Promise((resolve, reject) => {
1378
+ https.get(jinaUrl, (res) => {
1379
+ const chunks = [];
1380
+ res.on("data", (chunk) => chunks.push(chunk));
1381
+ res.on("end", () => {
1382
+ const text = Buffer.concat(chunks).toString("utf8");
1383
+ resolve(text.slice(0, maxChars));
1384
+ });
1385
+ res.on("error", reject);
1386
+ }).on("error", reject);
1387
+ });
1388
+ }
1389
+ /** Detect URLs in text, fetch each via Jina Reader, and prepend content to the prompt */
1390
+ export async function enrichPromptWithUrls(text) {
1391
+ const urlRegex = /https?:\/\/[^\s]+/g;
1392
+ const urls = text.match(urlRegex);
1393
+ if (!urls || urls.length === 0)
1394
+ return text;
1395
+ const prefixes = [];
1396
+ for (const url of urls) {
1397
+ // Skip jina.ai URLs to avoid recursion
1398
+ if (url.includes("r.jina.ai"))
1399
+ continue;
1400
+ try {
1401
+ const content = await fetchUrlViaJina(url);
1402
+ if (content.trim()) {
1403
+ prefixes.push(`[Web content from ${url}]:\n${content}`);
1404
+ }
1405
+ }
1406
+ catch (err) {
1407
+ console.warn(`[url-fetch] failed to fetch ${url}:`, err.message);
1408
+ }
1409
+ }
1410
+ if (prefixes.length === 0)
1411
+ return text;
1412
+ return prefixes.join("\n\n") + "\n\n" + text;
1413
+ }
1414
+ /** Parse frontmatter description from a skill markdown file */
1415
+ function parseSkillDescription(content) {
1416
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1417
+ if (!match)
1418
+ return null;
1419
+ const frontmatter = match[1];
1420
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
1421
+ return descMatch ? descMatch[1].trim() : null;
1422
+ }
1423
+ /** List available skills from ~/.claude/skills/ */
1424
+ export function listSkills() {
1425
+ const skillsDir = join(os.homedir(), ".claude", "skills");
1426
+ if (!existsSync(skillsDir)) {
1427
+ return "No skills directory found at ~/.claude/skills/";
1428
+ }
1429
+ let files;
1430
+ try {
1431
+ files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
1432
+ }
1433
+ catch {
1434
+ return "Could not read skills directory.";
1435
+ }
1436
+ if (files.length === 0) {
1437
+ return "No skills found in ~/.claude/skills/";
1438
+ }
1439
+ const lines = ["Available skills:"];
1440
+ for (const file of files.sort()) {
1441
+ const name = "/" + file.replace(/\.md$/, "");
1442
+ try {
1443
+ const content = readFileSync(join(skillsDir, file), "utf8");
1444
+ const description = parseSkillDescription(content);
1445
+ lines.push(description ? `${name} — ${description}` : name);
1446
+ }
1447
+ catch {
1448
+ lines.push(name);
1449
+ }
1450
+ }
1451
+ return lines.join("\n");
1452
+ }
1229
1453
  export function splitMessage(text, maxLen = 4096) {
1230
1454
  if (text.length <= maxLen)
1231
1455
  return [text];