@gonzih/cc-tg 0.6.2 → 0.6.3

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.
@@ -4,7 +4,7 @@
4
4
  * Listens to the `cca:events` pub/sub channel for job completion events,
5
5
  * asks Claude to decide what to do, and acts accordingly:
6
6
  * NOTIFY_ONLY — send a Telegram message to the configured chat
7
- * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP
7
+ * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP + notify Telegram
8
8
  * SILENT — log and do nothing
9
9
  *
10
10
  * Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
@@ -19,6 +19,13 @@ export interface JobEvent {
19
19
  score?: number;
20
20
  timestamp: number;
21
21
  }
22
+ export interface CoordinatorPlan {
23
+ nextStep?: {
24
+ repo_url: string;
25
+ task: string;
26
+ };
27
+ summary: string;
28
+ }
22
29
  export interface DecisionResult {
23
30
  action: "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT";
24
31
  message?: string;
@@ -32,8 +39,11 @@ export interface HandlerDeps {
32
39
  askClaude: (prompt: string) => Promise<string>;
33
40
  sendTelegramMessage: (chatId: number, text: string) => Promise<void>;
34
41
  spawnFollowupAgent: (repoUrl: string, task: string) => Promise<void>;
42
+ readJobOutput: (jobId: string) => Promise<string[]>;
43
+ readCoordinatorPlan: (jobId: string) => Promise<CoordinatorPlan | null>;
44
+ getRunningJobCount: () => Promise<number>;
35
45
  }
36
- export declare function buildDecisionPrompt(event: JobEvent): string;
46
+ export declare function buildDecisionPrompt(event: JobEvent, last40lines: string[], coordinatorPlan: CoordinatorPlan | null): string;
37
47
  export declare function parseDecision(raw: string): DecisionResult;
38
48
  /**
39
49
  * Ask Claude to make a decision about a completed job.
@@ -42,6 +52,21 @@ export declare function parseDecision(raw: string): DecisionResult;
42
52
  export declare function defaultAskClaude(prompt: string): Promise<string>;
43
53
  export declare function defaultSendTelegramMessage(chatId: number, text: string): Promise<void>;
44
54
  export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string): Promise<void>;
55
+ export declare function defaultReadJobOutput(jobId: string): Promise<string[]>;
56
+ export declare function defaultReadCoordinatorPlan(jobId: string): Promise<CoordinatorPlan | null>;
57
+ export declare function defaultGetRunningJobCount(): Promise<number>;
58
+ /**
59
+ * Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
60
+ * Call this when spawning a job that has a planned follow-up.
61
+ * TTL: 7 days.
62
+ */
63
+ export declare function writeCoordinatorPlan(jobId: string, plan: {
64
+ nextStep?: {
65
+ repo_url: string;
66
+ task: string;
67
+ };
68
+ summary: string;
69
+ }): Promise<void>;
45
70
  /**
46
71
  * Handle a single job event message from Redis pub/sub.
47
72
  * Exported for testability — production code passes defaultDeps.
@@ -4,7 +4,7 @@
4
4
  * Listens to the `cca:events` pub/sub channel for job completion events,
5
5
  * asks Claude to decide what to do, and acts accordingly:
6
6
  * NOTIFY_ONLY — send a Telegram message to the configured chat
7
- * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP
7
+ * SPAWN_FOLLOWUP — spawn a follow-up cc-agent job via MCP + notify Telegram
8
8
  * SILENT — log and do nothing
9
9
  *
10
10
  * Controlled via CC_AGENT_EVENTS_ENABLED env var (default: true).
@@ -21,31 +21,40 @@ function log(level, ...args) {
21
21
  : console.log;
22
22
  fn("[cc-agent-events]", ...args);
23
23
  }
24
- export function buildDecisionPrompt(event) {
24
+ export function buildDecisionPrompt(event, last40lines, coordinatorPlan) {
25
+ const scoreStr = event.score !== undefined ? String(event.score) : "n/a";
26
+ const planStr = coordinatorPlan ? JSON.stringify(coordinatorPlan, null, 2) : "none";
25
27
  return `A cc-agent job just completed.
26
28
 
27
29
  Job: ${event.title}
28
30
  Repo: ${event.repoUrl}
29
31
  Status: ${event.status}
30
- Last output:
31
- ${event.lastLines.join("\n")}
32
+ Score: ${scoreStr}
32
33
 
33
- Decide what to do next. Options:
34
- 1. NOTIFY_ONLY — send a brief Telegram message to Maksim summarizing what completed
35
- 2. SPAWN_FOLLOWUP — spawn a follow-up cc-agent job (provide repo_url and task)
36
- 3. SILENT — log it, no action needed (routine/expected completion)
34
+ Last output + LEARNINGS:
35
+ ${last40lines.join("\n")}
37
36
 
38
- Reply in this exact JSON format:
39
- {
40
- "action": "NOTIFY_ONLY" | "SPAWN_FOLLOWUP" | "SILENT",
41
- "message": "...",
42
- "followup": {
43
- "repo_url": "...",
44
- "task": "..."
45
- }
46
- }
37
+ Coordinator plan for this job (if any):
38
+ ${planStr}
39
+
40
+ Decide what to do next:
41
+ 1. SPAWN_FOLLOWUP — spawn a follow-up job (provide repo_url and task)
42
+ 2. NOTIFY_ONLY — send Telegram message, no spawn needed
43
+ 3. SILENT — routine completion, no action
44
+
45
+ Rules:
46
+ - If LEARNINGS has "Recommendations for next agent" with a clear actionable next step → consider SPAWN_FOLLOWUP
47
+ - If coordinator plan has nextStep → SPAWN_FOLLOWUP with that task (prefer coordinator plan over LEARNINGS)
48
+ - Failed jobs → NOTIFY_ONLY always
49
+ - Score < 0.5 → NOTIFY_ONLY
50
+ - Routine/expected completions → SILENT
47
51
 
48
- Be conservative. Only SPAWN_FOLLOWUP if clearly needed. Only NOTIFY_ONLY for important completions. Use SILENT for routine jobs.`;
52
+ Reply in JSON:
53
+ {
54
+ "action": "SPAWN_FOLLOWUP" | "NOTIFY_ONLY" | "SILENT",
55
+ "message": "brief telegram message (1-2 lines)",
56
+ "followup": { "repo_url": "...", "task": "..." } | null
57
+ }`;
49
58
  }
50
59
  export function parseDecision(raw) {
51
60
  const match = raw.match(/\{[\s\S]*\}/);
@@ -57,6 +66,32 @@ export function parseDecision(raw) {
57
66
  }
58
67
  return parsed;
59
68
  }
69
+ function formatSpawnMessage(event, followup, runningCount) {
70
+ const scoreStr = event.score !== undefined ? ` (score: ${event.score})` : "";
71
+ const repoShort = followup.repo_url.replace(/^https?:\/\/github\.com\//, "");
72
+ const lines = [
73
+ `✓ ${event.title} done${scoreStr}`,
74
+ `→ spawned: ${followup.task} (${repoShort})`,
75
+ ];
76
+ if (runningCount > 0) {
77
+ lines.push(`${runningCount} jobs running`);
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+ function formatFailureMessage(event) {
82
+ const lastLine = event.lastLines[event.lastLines.length - 1] ?? "";
83
+ const repoShort = event.repoUrl.replace(/^https?:\/\/github\.com\//, "");
84
+ return `✗ ${event.title} failed\n${repoShort} — exit 1\nLast line: ${lastLine}`;
85
+ }
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
+ }
60
95
  /**
61
96
  * Ask Claude to make a decision about a completed job.
62
97
  * Returns the raw text response from Claude.
@@ -149,6 +184,65 @@ Call the spawn_agent tool now with these exact parameters. Report the job ID whe
149
184
  claude.sendPrompt(prompt);
150
185
  });
151
186
  }
187
+ function makeRedisClient() {
188
+ return new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
189
+ lazyConnect: true,
190
+ enableOfflineQueue: false,
191
+ });
192
+ }
193
+ export async function defaultReadJobOutput(jobId) {
194
+ const redis = makeRedisClient();
195
+ try {
196
+ await redis.connect();
197
+ const lines = await redis.lrange(`cca:job:${jobId}:output`, -40, -1);
198
+ return lines;
199
+ }
200
+ finally {
201
+ try {
202
+ redis.disconnect();
203
+ }
204
+ catch { }
205
+ }
206
+ }
207
+ export async function defaultReadCoordinatorPlan(jobId) {
208
+ const redis = makeRedisClient();
209
+ try {
210
+ await redis.connect();
211
+ const raw = await redis.get(`cca:coordinator:plan:${jobId}`);
212
+ if (!raw)
213
+ return null;
214
+ return JSON.parse(raw);
215
+ }
216
+ finally {
217
+ try {
218
+ redis.disconnect();
219
+ }
220
+ catch { }
221
+ }
222
+ }
223
+ export async function defaultGetRunningJobCount() {
224
+ return 0;
225
+ }
226
+ /**
227
+ * Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
228
+ * Call this when spawning a job that has a planned follow-up.
229
+ * TTL: 7 days.
230
+ */
231
+ export async function writeCoordinatorPlan(jobId, plan) {
232
+ const redis = makeRedisClient();
233
+ try {
234
+ await redis.connect();
235
+ const key = `cca:coordinator:plan:${jobId}`;
236
+ const ttlSeconds = 7 * 24 * 60 * 60; // 7 days
237
+ await redis.set(key, JSON.stringify(plan), "EX", ttlSeconds);
238
+ }
239
+ finally {
240
+ try {
241
+ redis.disconnect();
242
+ }
243
+ catch { }
244
+ }
245
+ }
152
246
  /**
153
247
  * Handle a single job event message from Redis pub/sub.
154
248
  * Exported for testability — production code passes defaultDeps.
@@ -168,29 +262,47 @@ export async function handleJobEvent(message, deps) {
168
262
  return;
169
263
  }
170
264
  log("info", `Processing ${event.status} event for job: ${event.title} (${event.jobId})`);
265
+ // Read job output from Redis (fall back to event.lastLines on error)
266
+ let last40lines = event.lastLines;
267
+ try {
268
+ const lines = await deps.readJobOutput(event.jobId);
269
+ if (lines.length > 0)
270
+ last40lines = lines;
271
+ }
272
+ catch (err) {
273
+ log("warn", "Failed to read job output, using event.lastLines:", err.message);
274
+ }
275
+ // Read coordinator plan from Redis (fall back to null on error)
276
+ let coordinatorPlan = null;
277
+ try {
278
+ coordinatorPlan = await deps.readCoordinatorPlan(event.jobId);
279
+ }
280
+ catch (err) {
281
+ log("warn", "Failed to read coordinator plan:", err.message);
282
+ }
171
283
  let decision;
172
284
  try {
173
- const rawResponse = await deps.askClaude(buildDecisionPrompt(event));
285
+ const rawResponse = await deps.askClaude(buildDecisionPrompt(event, last40lines, coordinatorPlan));
174
286
  decision = parseDecision(rawResponse);
175
287
  }
176
288
  catch (err) {
177
- log("error", "Claude decision failed:", err.message);
178
- return;
289
+ log("error", "Claude decision failed, falling back to NOTIFY_ONLY:", err.message);
290
+ const fallbackMsg = event.status === "failed"
291
+ ? formatFailureMessage(event)
292
+ : `Job completed: ${event.title}`;
293
+ decision = { action: "NOTIFY_ONLY", message: fallbackMsg };
179
294
  }
180
295
  log("info", `Decision: ${decision.action} for job ${event.jobId}`);
296
+ const chatId = getChatId();
181
297
  try {
182
298
  if (decision.action === "NOTIFY_ONLY") {
183
- const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
184
- if (!chatIdStr) {
299
+ if (!chatId) {
185
300
  log("warn", "NOTIFY_ONLY: CC_AGENT_NOTIFY_CHAT_ID not set, skipping notification");
186
301
  return;
187
302
  }
188
- const chatId = Number(chatIdStr);
189
- if (isNaN(chatId)) {
190
- log("warn", `NOTIFY_ONLY: invalid CC_AGENT_NOTIFY_CHAT_ID: ${chatIdStr}`);
191
- return;
192
- }
193
- await deps.sendTelegramMessage(chatId, decision.message ?? `Job completed: ${event.title}`);
303
+ const msg = decision.message
304
+ ?? (event.status === "failed" ? formatFailureMessage(event) : `Job completed: ${event.title}`);
305
+ await deps.sendTelegramMessage(chatId, msg);
194
306
  }
195
307
  else if (decision.action === "SPAWN_FOLLOWUP") {
196
308
  if (!decision.followup) {
@@ -198,6 +310,16 @@ export async function handleJobEvent(message, deps) {
198
310
  return;
199
311
  }
200
312
  await deps.spawnFollowupAgent(decision.followup.repo_url, decision.followup.task);
313
+ // Send Telegram notification about the spawn
314
+ if (chatId) {
315
+ let runningCount = 0;
316
+ try {
317
+ runningCount = await deps.getRunningJobCount();
318
+ }
319
+ catch { }
320
+ const spawnMsg = formatSpawnMessage(event, decision.followup, runningCount);
321
+ await deps.sendTelegramMessage(chatId, spawnMsg);
322
+ }
201
323
  }
202
324
  else {
203
325
  // SILENT — log only
@@ -213,6 +335,9 @@ function makeDefaultDeps() {
213
335
  askClaude: defaultAskClaude,
214
336
  sendTelegramMessage: defaultSendTelegramMessage,
215
337
  spawnFollowupAgent: defaultSpawnFollowupAgent,
338
+ readJobOutput: defaultReadJobOutput,
339
+ readCoordinatorPlan: defaultReadCoordinatorPlan,
340
+ getRunningJobCount: defaultGetRunningJobCount,
216
341
  };
217
342
  }
218
343
  let subscriberClient = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-tg",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Claude Code Telegram bot — chat with Claude Code via Telegram",
5
5
  "type": "module",
6
6
  "bin": {