@gonzih/cc-tg 0.6.4 → 0.6.5

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.
@@ -10,6 +10,7 @@
10
10
  * Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
11
11
  * Requires CC_AGENT_NOTIFY_CHAT_ID to send Telegram notifications.
12
12
  */
13
+ import { Redis } from "ioredis";
13
14
  export interface JobEvent {
14
15
  jobId: string;
15
16
  status: "done" | "failed" | "interrupted" | "running" | "cancelled";
@@ -79,6 +80,16 @@ export declare function writeCoordinatorPlan(jobId: string, plan: {
79
80
  * Exported for testability — production code passes defaultDeps.
80
81
  */
81
82
  export declare function handleJobEvent(message: string, deps: HandlerDeps): Promise<void>;
83
+ /** Parse flat key-value field array from a Redis Stream entry into a record. */
84
+ export declare function parseStreamFields(fields: string[]): Record<string, string>;
85
+ /** Convert stream entry fields to a JobEvent JSON string for handleJobEvent. */
86
+ export declare function streamEntryToMessage(fields: Record<string, string>): string | null;
87
+ /**
88
+ * Replay events from the Redis Stream that were missed since last-seen ID.
89
+ * Uses `cca:event-stream:last-id:{botName}` in Redis to track position.
90
+ * Exported for testability — pass a real or mock Redis instance.
91
+ */
92
+ export declare function replayStreamEvents(redis: Redis, deps: HandlerDeps, botName?: string): Promise<void>;
82
93
  /**
83
94
  * Connect to Redis and subscribe to cca:events.
84
95
  * Reconnects automatically on disconnect.
@@ -14,6 +14,7 @@ import { readFileSync } from "fs";
14
14
  import { join } from "path";
15
15
  import { Redis } from "ioredis";
16
16
  import TelegramBot from "node-telegram-bot-api";
17
+ const STREAM_KEY = "cca:event-stream";
17
18
  import { ClaudeProcess, extractText } from "./claude.js";
18
19
  function log(level, ...args) {
19
20
  const fn = level === "error"
@@ -315,6 +316,39 @@ export async function handleJobEvent(message, deps) {
315
316
  catch (err) {
316
317
  log("warn", "Failed to read coordinator plan:", err.message);
317
318
  }
319
+ // Fast path: coordinator plan has explicit next step — spawn directly, no Claude needed
320
+ // This eliminates JSON truncation issues when Claude regenerates long task strings.
321
+ if (coordinatorPlan?.nextStep) {
322
+ log("info", `Fast path: coordinator plan nextStep found for job ${event.jobId}`);
323
+ const { repo_url, task } = coordinatorPlan.nextStep;
324
+ let fpChatIds = [];
325
+ try {
326
+ fpChatIds = await deps.getActiveChatIds();
327
+ }
328
+ catch (err) {
329
+ log("warn", "Fast path: failed to get active chat IDs:", err.message);
330
+ }
331
+ try {
332
+ await deps.spawnFollowupAgent(repo_url, task);
333
+ }
334
+ catch (err) {
335
+ log("error", "Fast path: spawnFollowupAgent failed:", err.message);
336
+ }
337
+ if (fpChatIds.length > 0) {
338
+ const scoreStr = event.score !== undefined ? ` (score: ${event.score})` : "";
339
+ const repoShort = repo_url.split("/").pop() ?? repo_url;
340
+ const msg = `✓ ${event.title} done${scoreStr}\n→ spawned: ${repoShort}`;
341
+ for (const chatId of fpChatIds) {
342
+ try {
343
+ await deps.sendTelegramMessage(chatId, msg);
344
+ }
345
+ catch (err) {
346
+ log("error", "Fast path: sendTelegramMessage failed:", err.message);
347
+ }
348
+ }
349
+ }
350
+ return;
351
+ }
318
352
  let decision;
319
353
  let rawResponse = "";
320
354
  try {
@@ -379,6 +413,80 @@ export async function handleJobEvent(message, deps) {
379
413
  log("error", `Action ${decision.action} failed:`, err.message);
380
414
  }
381
415
  }
416
+ /** Parse flat key-value field array from a Redis Stream entry into a record. */
417
+ export function parseStreamFields(fields) {
418
+ const obj = {};
419
+ for (let i = 0; i + 1 < fields.length; i += 2) {
420
+ obj[fields[i]] = fields[i + 1];
421
+ }
422
+ return obj;
423
+ }
424
+ /** Convert stream entry fields to a JobEvent JSON string for handleJobEvent. */
425
+ export function streamEntryToMessage(fields) {
426
+ try {
427
+ const score = fields["score"] !== undefined && fields["score"] !== ""
428
+ ? Number(fields["score"])
429
+ : undefined;
430
+ const event = {
431
+ jobId: fields["jobId"] ?? "",
432
+ status: (fields["status"] ?? "done"),
433
+ title: fields["title"] ?? "",
434
+ repoUrl: fields["repoUrl"] ?? "",
435
+ lastLines: JSON.parse(fields["lastLines"] ?? "[]"),
436
+ score,
437
+ timestamp: Number(fields["timestamp"] ?? Date.now()),
438
+ };
439
+ return JSON.stringify(event);
440
+ }
441
+ catch {
442
+ return null;
443
+ }
444
+ }
445
+ /**
446
+ * Replay events from the Redis Stream that were missed since last-seen ID.
447
+ * Uses `cca:event-stream:last-id:{botName}` in Redis to track position.
448
+ * Exported for testability — pass a real or mock Redis instance.
449
+ */
450
+ export async function replayStreamEvents(redis, deps, botName) {
451
+ const name = botName ?? (process.env.CC_TG_BOT_NAME ?? "cc-tg");
452
+ const lastIdKey = `cca:event-stream:last-id:${name}`;
453
+ let lastId = "0";
454
+ try {
455
+ lastId = (await redis.get(lastIdKey)) ?? "0";
456
+ }
457
+ catch (err) {
458
+ log("warn", "replayStreamEvents: failed to read last-id:", err.message);
459
+ }
460
+ let results = null;
461
+ try {
462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
+ results = (await redis.xread("COUNT", 20, "STREAMS", STREAM_KEY, lastId));
464
+ }
465
+ catch (err) {
466
+ log("warn", "replayStreamEvents: xread failed:", err.message);
467
+ return;
468
+ }
469
+ if (!results || results.length === 0)
470
+ return;
471
+ log("info", `Replaying missed stream events from last-id=${lastId}`);
472
+ for (const [, entries] of results) {
473
+ for (const [id, fields] of entries) {
474
+ const message = streamEntryToMessage(parseStreamFields(fields));
475
+ if (message) {
476
+ await handleJobEvent(message, deps).catch((err) => {
477
+ log("error", `replayStreamEvents: handleJobEvent error for entry ${id}:`, err.message);
478
+ });
479
+ }
480
+ try {
481
+ await redis.set(lastIdKey, id);
482
+ }
483
+ catch (err) {
484
+ log("warn", "replayStreamEvents: failed to update last-id:", err.message);
485
+ }
486
+ }
487
+ }
488
+ log("info", "Stream replay complete.");
489
+ }
382
490
  function makeDefaultDeps() {
383
491
  return {
384
492
  askClaude: defaultAskClaude,
@@ -405,10 +513,19 @@ export async function connectEventSubscriber() {
405
513
  }
406
514
  async function connectWithBackoff(attempt) {
407
515
  const delay = Math.min(5_000 * Math.pow(2, attempt), 60_000);
408
- const sub = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
516
+ const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
517
+ const botName = process.env.CC_TG_BOT_NAME ?? "cc-tg";
518
+ const lastIdKey = `cca:event-stream:last-id:${botName}`;
519
+ // Pub/sub subscriber client — enters subscriber mode after subscribe()
520
+ const sub = new Redis(redisUrl, {
409
521
  lazyConnect: true,
410
522
  enableOfflineQueue: false,
411
523
  });
524
+ // Regular command client — stays in normal mode for xread/get/set
525
+ const reg = new Redis(redisUrl, {
526
+ lazyConnect: true,
527
+ enableOfflineQueue: true,
528
+ });
412
529
  subscriberClient = sub;
413
530
  sub.on("error", (err) => {
414
531
  log("warn", "subscriber error, reconnecting...", err.message);
@@ -416,8 +533,15 @@ async function connectWithBackoff(attempt) {
416
533
  sub.disconnect();
417
534
  }
418
535
  catch { }
536
+ try {
537
+ reg.disconnect();
538
+ }
539
+ catch { }
419
540
  setTimeout(() => connectWithBackoff(0), 5_000);
420
541
  });
542
+ reg.on("error", (err) => {
543
+ log("warn", "regular client error (non-fatal):", err.message);
544
+ });
421
545
  try {
422
546
  await sub.connect();
423
547
  }
@@ -430,11 +554,36 @@ async function connectWithBackoff(attempt) {
430
554
  setTimeout(() => connectWithBackoff(attempt + 1), delay);
431
555
  return;
432
556
  }
557
+ // Connect regular client (best-effort — stream replay is non-critical)
558
+ try {
559
+ await reg.connect();
560
+ }
561
+ catch (err) {
562
+ log("warn", "Regular Redis client connect failed (stream replay skipped):", err.message);
563
+ }
433
564
  const deps = makeDefaultDeps();
565
+ // Replay events missed during downtime, then mark current time as last-id
566
+ // Must happen BEFORE sub.subscribe() because subscribe() puts sub in subscriber mode
567
+ try {
568
+ await replayStreamEvents(reg, deps, botName);
569
+ }
570
+ catch (err) {
571
+ log("warn", "Stream replay failed, continuing:", err.message);
572
+ }
573
+ // Mark current timestamp so next restart only replays events after now
574
+ try {
575
+ await reg.set(lastIdKey, `${Date.now()}-0`);
576
+ }
577
+ catch {
578
+ // Non-fatal
579
+ }
434
580
  sub.on("message", (channel, message) => {
435
581
  if (channel !== "cca:events")
436
582
  return;
437
- handleJobEvent(message, deps).catch((err) => {
583
+ handleJobEvent(message, deps).then(() => {
584
+ // Advance stream last-id so next restart doesn't re-replay this event
585
+ reg.set(lastIdKey, `${Date.now()}-0`).catch(() => { });
586
+ }).catch((err) => {
438
587
  log("error", "handleJobEvent uncaught:", err.message);
439
588
  });
440
589
  });
@@ -448,6 +597,10 @@ async function connectWithBackoff(attempt) {
448
597
  sub.disconnect();
449
598
  }
450
599
  catch { }
600
+ try {
601
+ reg.disconnect();
602
+ }
603
+ catch { }
451
604
  setTimeout(() => connectWithBackoff(attempt + 1), delay);
452
605
  return;
453
606
  }
@@ -456,6 +609,7 @@ async function connectWithBackoff(attempt) {
456
609
  try {
457
610
  await sub.unsubscribe("cca:events");
458
611
  sub.disconnect();
612
+ reg.disconnect();
459
613
  }
460
614
  catch { }
461
615
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {