@gonzih/cc-tg 0.9.19 → 0.9.21

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