@gonzih/cc-tg 0.6.3 → 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";
@@ -42,6 +43,7 @@ export interface HandlerDeps {
42
43
  readJobOutput: (jobId: string) => Promise<string[]>;
43
44
  readCoordinatorPlan: (jobId: string) => Promise<CoordinatorPlan | null>;
44
45
  getRunningJobCount: () => Promise<number>;
46
+ getActiveChatIds: () => Promise<number[]>;
45
47
  }
46
48
  export declare function buildDecisionPrompt(event: JobEvent, last40lines: string[], coordinatorPlan: CoordinatorPlan | null): string;
47
49
  export declare function parseDecision(raw: string): DecisionResult;
@@ -55,6 +57,12 @@ export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string)
55
57
  export declare function defaultReadJobOutput(jobId: string): Promise<string[]>;
56
58
  export declare function defaultReadCoordinatorPlan(jobId: string): Promise<CoordinatorPlan | null>;
57
59
  export declare function defaultGetRunningJobCount(): Promise<number>;
60
+ /**
61
+ * Returns chat IDs to notify about job events.
62
+ * Reads unique chatIds from the cron jobs file (same users who set up cron jobs).
63
+ * Falls back to CC_AGENT_NOTIFY_CHAT_ID env var for backward compatibility.
64
+ */
65
+ export declare function defaultGetActiveChatIds(): Promise<number[]>;
58
66
  /**
59
67
  * Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
60
68
  * Call this when spawning a job that has a planned follow-up.
@@ -72,6 +80,16 @@ export declare function writeCoordinatorPlan(jobId: string, plan: {
72
80
  * Exported for testability — production code passes defaultDeps.
73
81
  */
74
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>;
75
93
  /**
76
94
  * Connect to Redis and subscribe to cca:events.
77
95
  * Reconnects automatically on disconnect.
@@ -10,8 +10,11 @@
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 { readFileSync } from "fs";
14
+ import { join } from "path";
13
15
  import { Redis } from "ioredis";
14
16
  import TelegramBot from "node-telegram-bot-api";
17
+ const STREAM_KEY = "cca:event-stream";
15
18
  import { ClaudeProcess, extractText } from "./claude.js";
16
19
  function log(level, ...args) {
17
20
  const fn = level === "error"
@@ -56,11 +59,23 @@ Reply in JSON:
56
59
  "followup": { "repo_url": "...", "task": "..." } | null
57
60
  }`;
58
61
  }
62
+ function extractJson(text) {
63
+ // Strip ```json ... ``` fences
64
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
65
+ if (fenced)
66
+ return fenced[1].trim();
67
+ // Find first { ... } block
68
+ const start = text.indexOf("{");
69
+ const end = text.lastIndexOf("}");
70
+ if (start !== -1 && end !== -1)
71
+ return text.slice(start, end + 1);
72
+ return "";
73
+ }
59
74
  export function parseDecision(raw) {
60
- const match = raw.match(/\{[\s\S]*\}/);
61
- if (!match)
75
+ const extracted = extractJson(raw);
76
+ if (!extracted)
62
77
  throw new Error(`No JSON found in Claude response: ${raw.slice(0, 200)}`);
63
- const parsed = JSON.parse(match[0]);
78
+ const parsed = JSON.parse(extracted);
64
79
  if (!["NOTIFY_ONLY", "SPAWN_FOLLOWUP", "SILENT"].includes(parsed.action)) {
65
80
  throw new Error(`Unknown action: ${parsed.action}`);
66
81
  }
@@ -83,15 +98,6 @@ function formatFailureMessage(event) {
83
98
  const repoShort = event.repoUrl.replace(/^https?:\/\/github\.com\//, "");
84
99
  return `✗ ${event.title} failed\n${repoShort} — exit 1\nLast line: ${lastLine}`;
85
100
  }
86
- function getChatId() {
87
- const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
88
- if (!chatIdStr)
89
- return null;
90
- const chatId = Number(chatIdStr);
91
- if (isNaN(chatId))
92
- return null;
93
- return chatId;
94
- }
95
101
  /**
96
102
  * Ask Claude to make a decision about a completed job.
97
103
  * Returns the raw text response from Claude.
@@ -223,6 +229,36 @@ export async function defaultReadCoordinatorPlan(jobId) {
223
229
  export async function defaultGetRunningJobCount() {
224
230
  return 0;
225
231
  }
232
+ /**
233
+ * Returns chat IDs to notify about job events.
234
+ * Reads unique chatIds from the cron jobs file (same users who set up cron jobs).
235
+ * Falls back to CC_AGENT_NOTIFY_CHAT_ID env var for backward compatibility.
236
+ */
237
+ export async function defaultGetActiveChatIds() {
238
+ const ids = new Set();
239
+ // Backward compat: explicit env var
240
+ const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
241
+ if (chatIdStr) {
242
+ const chatId = Number(chatIdStr);
243
+ if (!isNaN(chatId))
244
+ ids.add(chatId);
245
+ }
246
+ // Read chatIds from cron jobs persistence file
247
+ try {
248
+ const cwd = process.env.CWD ?? process.cwd();
249
+ const cronFile = join(cwd, ".cc-tg", "crons.json");
250
+ const raw = readFileSync(cronFile, "utf-8");
251
+ const jobs = JSON.parse(raw);
252
+ for (const job of jobs) {
253
+ if (typeof job.chatId === "number")
254
+ ids.add(job.chatId);
255
+ }
256
+ }
257
+ catch {
258
+ // file doesn't exist or parse error — ignore
259
+ }
260
+ return Array.from(ids);
261
+ }
226
262
  /**
227
263
  * Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
228
264
  * Call this when spawning a job that has a planned follow-up.
@@ -280,12 +316,49 @@ export async function handleJobEvent(message, deps) {
280
316
  catch (err) {
281
317
  log("warn", "Failed to read coordinator plan:", err.message);
282
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
+ }
283
352
  let decision;
353
+ let rawResponse = "";
284
354
  try {
285
- const rawResponse = await deps.askClaude(buildDecisionPrompt(event, last40lines, coordinatorPlan));
355
+ rawResponse = await deps.askClaude(buildDecisionPrompt(event, last40lines, coordinatorPlan));
286
356
  decision = parseDecision(rawResponse);
287
357
  }
288
358
  catch (err) {
359
+ if (rawResponse) {
360
+ log("error", "[cc-agent-events] Claude raw response:", rawResponse.slice(0, 200));
361
+ }
289
362
  log("error", "Claude decision failed, falling back to NOTIFY_ONLY:", err.message);
290
363
  const fallbackMsg = event.status === "failed"
291
364
  ? formatFailureMessage(event)
@@ -293,16 +366,24 @@ export async function handleJobEvent(message, deps) {
293
366
  decision = { action: "NOTIFY_ONLY", message: fallbackMsg };
294
367
  }
295
368
  log("info", `Decision: ${decision.action} for job ${event.jobId}`);
296
- const chatId = getChatId();
369
+ let chatIds = [];
370
+ try {
371
+ chatIds = await deps.getActiveChatIds();
372
+ }
373
+ catch (err) {
374
+ log("warn", "Failed to get active chat IDs:", err.message);
375
+ }
297
376
  try {
298
377
  if (decision.action === "NOTIFY_ONLY") {
299
- if (!chatId) {
300
- log("warn", "NOTIFY_ONLY: CC_AGENT_NOTIFY_CHAT_ID not set, skipping notification");
378
+ if (chatIds.length === 0) {
379
+ log("warn", "NOTIFY_ONLY: no active chat IDs, skipping notification");
301
380
  return;
302
381
  }
303
382
  const msg = decision.message
304
383
  ?? (event.status === "failed" ? formatFailureMessage(event) : `Job completed: ${event.title}`);
305
- await deps.sendTelegramMessage(chatId, msg);
384
+ for (const chatId of chatIds) {
385
+ await deps.sendTelegramMessage(chatId, msg);
386
+ }
306
387
  }
307
388
  else if (decision.action === "SPAWN_FOLLOWUP") {
308
389
  if (!decision.followup) {
@@ -311,14 +392,16 @@ export async function handleJobEvent(message, deps) {
311
392
  }
312
393
  await deps.spawnFollowupAgent(decision.followup.repo_url, decision.followup.task);
313
394
  // Send Telegram notification about the spawn
314
- if (chatId) {
395
+ if (chatIds.length > 0) {
315
396
  let runningCount = 0;
316
397
  try {
317
398
  runningCount = await deps.getRunningJobCount();
318
399
  }
319
400
  catch { }
320
401
  const spawnMsg = formatSpawnMessage(event, decision.followup, runningCount);
321
- await deps.sendTelegramMessage(chatId, spawnMsg);
402
+ for (const chatId of chatIds) {
403
+ await deps.sendTelegramMessage(chatId, spawnMsg);
404
+ }
322
405
  }
323
406
  }
324
407
  else {
@@ -330,6 +413,80 @@ export async function handleJobEvent(message, deps) {
330
413
  log("error", `Action ${decision.action} failed:`, err.message);
331
414
  }
332
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
+ }
333
490
  function makeDefaultDeps() {
334
491
  return {
335
492
  askClaude: defaultAskClaude,
@@ -338,6 +495,7 @@ function makeDefaultDeps() {
338
495
  readJobOutput: defaultReadJobOutput,
339
496
  readCoordinatorPlan: defaultReadCoordinatorPlan,
340
497
  getRunningJobCount: defaultGetRunningJobCount,
498
+ getActiveChatIds: defaultGetActiveChatIds,
341
499
  };
342
500
  }
343
501
  let subscriberClient = null;
@@ -355,10 +513,19 @@ export async function connectEventSubscriber() {
355
513
  }
356
514
  async function connectWithBackoff(attempt) {
357
515
  const delay = Math.min(5_000 * Math.pow(2, attempt), 60_000);
358
- 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, {
359
521
  lazyConnect: true,
360
522
  enableOfflineQueue: false,
361
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
+ });
362
529
  subscriberClient = sub;
363
530
  sub.on("error", (err) => {
364
531
  log("warn", "subscriber error, reconnecting...", err.message);
@@ -366,8 +533,15 @@ async function connectWithBackoff(attempt) {
366
533
  sub.disconnect();
367
534
  }
368
535
  catch { }
536
+ try {
537
+ reg.disconnect();
538
+ }
539
+ catch { }
369
540
  setTimeout(() => connectWithBackoff(0), 5_000);
370
541
  });
542
+ reg.on("error", (err) => {
543
+ log("warn", "regular client error (non-fatal):", err.message);
544
+ });
371
545
  try {
372
546
  await sub.connect();
373
547
  }
@@ -380,11 +554,36 @@ async function connectWithBackoff(attempt) {
380
554
  setTimeout(() => connectWithBackoff(attempt + 1), delay);
381
555
  return;
382
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
+ }
383
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
+ }
384
580
  sub.on("message", (channel, message) => {
385
581
  if (channel !== "cca:events")
386
582
  return;
387
- 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) => {
388
587
  log("error", "handleJobEvent uncaught:", err.message);
389
588
  });
390
589
  });
@@ -398,6 +597,10 @@ async function connectWithBackoff(attempt) {
398
597
  sub.disconnect();
399
598
  }
400
599
  catch { }
600
+ try {
601
+ reg.disconnect();
602
+ }
603
+ catch { }
401
604
  setTimeout(() => connectWithBackoff(attempt + 1), delay);
402
605
  return;
403
606
  }
@@ -406,6 +609,7 @@ async function connectWithBackoff(attempt) {
406
609
  try {
407
610
  await sub.unsubscribe("cca:events");
408
611
  sub.disconnect();
612
+ reg.disconnect();
409
613
  }
410
614
  catch { }
411
615
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.6.3",
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": {