@gonzih/cc-tg 0.6.2 → 0.6.4
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/cc-agent-events.d.ts +34 -2
- package/dist/cc-agent-events.js +206 -31
- package/package.json +1 -1
|
@@ -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,12 @@ 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>;
|
|
45
|
+
getActiveChatIds: () => Promise<number[]>;
|
|
35
46
|
}
|
|
36
|
-
export declare function buildDecisionPrompt(event: JobEvent): string;
|
|
47
|
+
export declare function buildDecisionPrompt(event: JobEvent, last40lines: string[], coordinatorPlan: CoordinatorPlan | null): string;
|
|
37
48
|
export declare function parseDecision(raw: string): DecisionResult;
|
|
38
49
|
/**
|
|
39
50
|
* Ask Claude to make a decision about a completed job.
|
|
@@ -42,6 +53,27 @@ export declare function parseDecision(raw: string): DecisionResult;
|
|
|
42
53
|
export declare function defaultAskClaude(prompt: string): Promise<string>;
|
|
43
54
|
export declare function defaultSendTelegramMessage(chatId: number, text: string): Promise<void>;
|
|
44
55
|
export declare function defaultSpawnFollowupAgent(repoUrl: string, task: string): Promise<void>;
|
|
56
|
+
export declare function defaultReadJobOutput(jobId: string): Promise<string[]>;
|
|
57
|
+
export declare function defaultReadCoordinatorPlan(jobId: string): Promise<CoordinatorPlan | null>;
|
|
58
|
+
export declare function defaultGetRunningJobCount(): Promise<number>;
|
|
59
|
+
/**
|
|
60
|
+
* Returns chat IDs to notify about job events.
|
|
61
|
+
* Reads unique chatIds from the cron jobs file (same users who set up cron jobs).
|
|
62
|
+
* Falls back to CC_AGENT_NOTIFY_CHAT_ID env var for backward compatibility.
|
|
63
|
+
*/
|
|
64
|
+
export declare function defaultGetActiveChatIds(): Promise<number[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
|
|
67
|
+
* Call this when spawning a job that has a planned follow-up.
|
|
68
|
+
* TTL: 7 days.
|
|
69
|
+
*/
|
|
70
|
+
export declare function writeCoordinatorPlan(jobId: string, plan: {
|
|
71
|
+
nextStep?: {
|
|
72
|
+
repo_url: string;
|
|
73
|
+
task: string;
|
|
74
|
+
};
|
|
75
|
+
summary: string;
|
|
76
|
+
}): Promise<void>;
|
|
45
77
|
/**
|
|
46
78
|
* Handle a single job event message from Redis pub/sub.
|
|
47
79
|
* Exported for testability — production code passes defaultDeps.
|
package/dist/cc-agent-events.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
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).
|
|
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";
|
|
15
17
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
@@ -21,42 +23,80 @@ function log(level, ...args) {
|
|
|
21
23
|
: console.log;
|
|
22
24
|
fn("[cc-agent-events]", ...args);
|
|
23
25
|
}
|
|
24
|
-
export function buildDecisionPrompt(event) {
|
|
26
|
+
export function buildDecisionPrompt(event, last40lines, coordinatorPlan) {
|
|
27
|
+
const scoreStr = event.score !== undefined ? String(event.score) : "n/a";
|
|
28
|
+
const planStr = coordinatorPlan ? JSON.stringify(coordinatorPlan, null, 2) : "none";
|
|
25
29
|
return `A cc-agent job just completed.
|
|
26
30
|
|
|
27
31
|
Job: ${event.title}
|
|
28
32
|
Repo: ${event.repoUrl}
|
|
29
33
|
Status: ${event.status}
|
|
30
|
-
|
|
31
|
-
${event.lastLines.join("\n")}
|
|
34
|
+
Score: ${scoreStr}
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
36
|
+
Last output + LEARNINGS:
|
|
37
|
+
${last40lines.join("\n")}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
Coordinator plan for this job (if any):
|
|
40
|
+
${planStr}
|
|
41
|
+
|
|
42
|
+
Decide what to do next:
|
|
43
|
+
1. SPAWN_FOLLOWUP — spawn a follow-up job (provide repo_url and task)
|
|
44
|
+
2. NOTIFY_ONLY — send Telegram message, no spawn needed
|
|
45
|
+
3. SILENT — routine completion, no action
|
|
46
|
+
|
|
47
|
+
Rules:
|
|
48
|
+
- If LEARNINGS has "Recommendations for next agent" with a clear actionable next step → consider SPAWN_FOLLOWUP
|
|
49
|
+
- If coordinator plan has nextStep → SPAWN_FOLLOWUP with that task (prefer coordinator plan over LEARNINGS)
|
|
50
|
+
- Failed jobs → NOTIFY_ONLY always
|
|
51
|
+
- Score < 0.5 → NOTIFY_ONLY
|
|
52
|
+
- Routine/expected completions → SILENT
|
|
53
|
+
|
|
54
|
+
Reply in JSON:
|
|
39
55
|
{
|
|
40
|
-
"action": "
|
|
41
|
-
"message": "
|
|
42
|
-
"followup": {
|
|
43
|
-
|
|
44
|
-
"task": "..."
|
|
45
|
-
}
|
|
56
|
+
"action": "SPAWN_FOLLOWUP" | "NOTIFY_ONLY" | "SILENT",
|
|
57
|
+
"message": "brief telegram message (1-2 lines)",
|
|
58
|
+
"followup": { "repo_url": "...", "task": "..." } | null
|
|
59
|
+
}`;
|
|
46
60
|
}
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
function extractJson(text) {
|
|
62
|
+
// Strip ```json ... ``` fences
|
|
63
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
64
|
+
if (fenced)
|
|
65
|
+
return fenced[1].trim();
|
|
66
|
+
// Find first { ... } block
|
|
67
|
+
const start = text.indexOf("{");
|
|
68
|
+
const end = text.lastIndexOf("}");
|
|
69
|
+
if (start !== -1 && end !== -1)
|
|
70
|
+
return text.slice(start, end + 1);
|
|
71
|
+
return "";
|
|
49
72
|
}
|
|
50
73
|
export function parseDecision(raw) {
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
74
|
+
const extracted = extractJson(raw);
|
|
75
|
+
if (!extracted)
|
|
53
76
|
throw new Error(`No JSON found in Claude response: ${raw.slice(0, 200)}`);
|
|
54
|
-
const parsed = JSON.parse(
|
|
77
|
+
const parsed = JSON.parse(extracted);
|
|
55
78
|
if (!["NOTIFY_ONLY", "SPAWN_FOLLOWUP", "SILENT"].includes(parsed.action)) {
|
|
56
79
|
throw new Error(`Unknown action: ${parsed.action}`);
|
|
57
80
|
}
|
|
58
81
|
return parsed;
|
|
59
82
|
}
|
|
83
|
+
function formatSpawnMessage(event, followup, runningCount) {
|
|
84
|
+
const scoreStr = event.score !== undefined ? ` (score: ${event.score})` : "";
|
|
85
|
+
const repoShort = followup.repo_url.replace(/^https?:\/\/github\.com\//, "");
|
|
86
|
+
const lines = [
|
|
87
|
+
`✓ ${event.title} done${scoreStr}`,
|
|
88
|
+
`→ spawned: ${followup.task} (${repoShort})`,
|
|
89
|
+
];
|
|
90
|
+
if (runningCount > 0) {
|
|
91
|
+
lines.push(`${runningCount} jobs running`);
|
|
92
|
+
}
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
function formatFailureMessage(event) {
|
|
96
|
+
const lastLine = event.lastLines[event.lastLines.length - 1] ?? "";
|
|
97
|
+
const repoShort = event.repoUrl.replace(/^https?:\/\/github\.com\//, "");
|
|
98
|
+
return `✗ ${event.title} failed\n${repoShort} — exit 1\nLast line: ${lastLine}`;
|
|
99
|
+
}
|
|
60
100
|
/**
|
|
61
101
|
* Ask Claude to make a decision about a completed job.
|
|
62
102
|
* Returns the raw text response from Claude.
|
|
@@ -149,6 +189,95 @@ Call the spawn_agent tool now with these exact parameters. Report the job ID whe
|
|
|
149
189
|
claude.sendPrompt(prompt);
|
|
150
190
|
});
|
|
151
191
|
}
|
|
192
|
+
function makeRedisClient() {
|
|
193
|
+
return new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
|
|
194
|
+
lazyConnect: true,
|
|
195
|
+
enableOfflineQueue: false,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
export async function defaultReadJobOutput(jobId) {
|
|
199
|
+
const redis = makeRedisClient();
|
|
200
|
+
try {
|
|
201
|
+
await redis.connect();
|
|
202
|
+
const lines = await redis.lrange(`cca:job:${jobId}:output`, -40, -1);
|
|
203
|
+
return lines;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
try {
|
|
207
|
+
redis.disconnect();
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
export async function defaultReadCoordinatorPlan(jobId) {
|
|
213
|
+
const redis = makeRedisClient();
|
|
214
|
+
try {
|
|
215
|
+
await redis.connect();
|
|
216
|
+
const raw = await redis.get(`cca:coordinator:plan:${jobId}`);
|
|
217
|
+
if (!raw)
|
|
218
|
+
return null;
|
|
219
|
+
return JSON.parse(raw);
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
try {
|
|
223
|
+
redis.disconnect();
|
|
224
|
+
}
|
|
225
|
+
catch { }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export async function defaultGetRunningJobCount() {
|
|
229
|
+
return 0;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Returns chat IDs to notify about job events.
|
|
233
|
+
* Reads unique chatIds from the cron jobs file (same users who set up cron jobs).
|
|
234
|
+
* Falls back to CC_AGENT_NOTIFY_CHAT_ID env var for backward compatibility.
|
|
235
|
+
*/
|
|
236
|
+
export async function defaultGetActiveChatIds() {
|
|
237
|
+
const ids = new Set();
|
|
238
|
+
// Backward compat: explicit env var
|
|
239
|
+
const chatIdStr = process.env.CC_AGENT_NOTIFY_CHAT_ID;
|
|
240
|
+
if (chatIdStr) {
|
|
241
|
+
const chatId = Number(chatIdStr);
|
|
242
|
+
if (!isNaN(chatId))
|
|
243
|
+
ids.add(chatId);
|
|
244
|
+
}
|
|
245
|
+
// Read chatIds from cron jobs persistence file
|
|
246
|
+
try {
|
|
247
|
+
const cwd = process.env.CWD ?? process.cwd();
|
|
248
|
+
const cronFile = join(cwd, ".cc-tg", "crons.json");
|
|
249
|
+
const raw = readFileSync(cronFile, "utf-8");
|
|
250
|
+
const jobs = JSON.parse(raw);
|
|
251
|
+
for (const job of jobs) {
|
|
252
|
+
if (typeof job.chatId === "number")
|
|
253
|
+
ids.add(job.chatId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// file doesn't exist or parse error — ignore
|
|
258
|
+
}
|
|
259
|
+
return Array.from(ids);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Write a coordinator plan for a job, so cc-tg knows what follow-up to spawn.
|
|
263
|
+
* Call this when spawning a job that has a planned follow-up.
|
|
264
|
+
* TTL: 7 days.
|
|
265
|
+
*/
|
|
266
|
+
export async function writeCoordinatorPlan(jobId, plan) {
|
|
267
|
+
const redis = makeRedisClient();
|
|
268
|
+
try {
|
|
269
|
+
await redis.connect();
|
|
270
|
+
const key = `cca:coordinator:plan:${jobId}`;
|
|
271
|
+
const ttlSeconds = 7 * 24 * 60 * 60; // 7 days
|
|
272
|
+
await redis.set(key, JSON.stringify(plan), "EX", ttlSeconds);
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
try {
|
|
276
|
+
redis.disconnect();
|
|
277
|
+
}
|
|
278
|
+
catch { }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
152
281
|
/**
|
|
153
282
|
* Handle a single job event message from Redis pub/sub.
|
|
154
283
|
* Exported for testability — production code passes defaultDeps.
|
|
@@ -168,29 +297,59 @@ export async function handleJobEvent(message, deps) {
|
|
|
168
297
|
return;
|
|
169
298
|
}
|
|
170
299
|
log("info", `Processing ${event.status} event for job: ${event.title} (${event.jobId})`);
|
|
300
|
+
// Read job output from Redis (fall back to event.lastLines on error)
|
|
301
|
+
let last40lines = event.lastLines;
|
|
302
|
+
try {
|
|
303
|
+
const lines = await deps.readJobOutput(event.jobId);
|
|
304
|
+
if (lines.length > 0)
|
|
305
|
+
last40lines = lines;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
log("warn", "Failed to read job output, using event.lastLines:", err.message);
|
|
309
|
+
}
|
|
310
|
+
// Read coordinator plan from Redis (fall back to null on error)
|
|
311
|
+
let coordinatorPlan = null;
|
|
312
|
+
try {
|
|
313
|
+
coordinatorPlan = await deps.readCoordinatorPlan(event.jobId);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
log("warn", "Failed to read coordinator plan:", err.message);
|
|
317
|
+
}
|
|
171
318
|
let decision;
|
|
319
|
+
let rawResponse = "";
|
|
172
320
|
try {
|
|
173
|
-
|
|
321
|
+
rawResponse = await deps.askClaude(buildDecisionPrompt(event, last40lines, coordinatorPlan));
|
|
174
322
|
decision = parseDecision(rawResponse);
|
|
175
323
|
}
|
|
176
324
|
catch (err) {
|
|
177
|
-
|
|
178
|
-
|
|
325
|
+
if (rawResponse) {
|
|
326
|
+
log("error", "[cc-agent-events] Claude raw response:", rawResponse.slice(0, 200));
|
|
327
|
+
}
|
|
328
|
+
log("error", "Claude decision failed, falling back to NOTIFY_ONLY:", err.message);
|
|
329
|
+
const fallbackMsg = event.status === "failed"
|
|
330
|
+
? formatFailureMessage(event)
|
|
331
|
+
: `Job completed: ${event.title}`;
|
|
332
|
+
decision = { action: "NOTIFY_ONLY", message: fallbackMsg };
|
|
179
333
|
}
|
|
180
334
|
log("info", `Decision: ${decision.action} for job ${event.jobId}`);
|
|
335
|
+
let chatIds = [];
|
|
336
|
+
try {
|
|
337
|
+
chatIds = await deps.getActiveChatIds();
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
log("warn", "Failed to get active chat IDs:", err.message);
|
|
341
|
+
}
|
|
181
342
|
try {
|
|
182
343
|
if (decision.action === "NOTIFY_ONLY") {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
log("warn", "NOTIFY_ONLY: CC_AGENT_NOTIFY_CHAT_ID not set, skipping notification");
|
|
344
|
+
if (chatIds.length === 0) {
|
|
345
|
+
log("warn", "NOTIFY_ONLY: no active chat IDs, skipping notification");
|
|
186
346
|
return;
|
|
187
347
|
}
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
348
|
+
const msg = decision.message
|
|
349
|
+
?? (event.status === "failed" ? formatFailureMessage(event) : `Job completed: ${event.title}`);
|
|
350
|
+
for (const chatId of chatIds) {
|
|
351
|
+
await deps.sendTelegramMessage(chatId, msg);
|
|
192
352
|
}
|
|
193
|
-
await deps.sendTelegramMessage(chatId, decision.message ?? `Job completed: ${event.title}`);
|
|
194
353
|
}
|
|
195
354
|
else if (decision.action === "SPAWN_FOLLOWUP") {
|
|
196
355
|
if (!decision.followup) {
|
|
@@ -198,6 +357,18 @@ export async function handleJobEvent(message, deps) {
|
|
|
198
357
|
return;
|
|
199
358
|
}
|
|
200
359
|
await deps.spawnFollowupAgent(decision.followup.repo_url, decision.followup.task);
|
|
360
|
+
// Send Telegram notification about the spawn
|
|
361
|
+
if (chatIds.length > 0) {
|
|
362
|
+
let runningCount = 0;
|
|
363
|
+
try {
|
|
364
|
+
runningCount = await deps.getRunningJobCount();
|
|
365
|
+
}
|
|
366
|
+
catch { }
|
|
367
|
+
const spawnMsg = formatSpawnMessage(event, decision.followup, runningCount);
|
|
368
|
+
for (const chatId of chatIds) {
|
|
369
|
+
await deps.sendTelegramMessage(chatId, spawnMsg);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
201
372
|
}
|
|
202
373
|
else {
|
|
203
374
|
// SILENT — log only
|
|
@@ -213,6 +384,10 @@ function makeDefaultDeps() {
|
|
|
213
384
|
askClaude: defaultAskClaude,
|
|
214
385
|
sendTelegramMessage: defaultSendTelegramMessage,
|
|
215
386
|
spawnFollowupAgent: defaultSpawnFollowupAgent,
|
|
387
|
+
readJobOutput: defaultReadJobOutput,
|
|
388
|
+
readCoordinatorPlan: defaultReadCoordinatorPlan,
|
|
389
|
+
getRunningJobCount: defaultGetRunningJobCount,
|
|
390
|
+
getActiveChatIds: defaultGetActiveChatIds,
|
|
216
391
|
};
|
|
217
392
|
}
|
|
218
393
|
let subscriberClient = null;
|