@gonzih/cc-discord 0.2.4 → 0.2.6
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.d.ts +18 -1
- package/dist/bot.js +160 -10
- package/dist/index.js +1 -1
- package/dist/loop-manager.d.ts +66 -0
- package/dist/loop-manager.js +118 -0
- package/dist/meta-agent-manager.d.ts +1 -5
- package/dist/meta-agent-manager.js +20 -68
- package/dist/notifier.d.ts +19 -12
- package/dist/notifier.js +63 -68
- package/package.json +1 -1
package/dist/bot.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
|
|
4
4
|
*/
|
|
5
5
|
import { Redis } from "ioredis";
|
|
6
|
+
import { type EvalReport } from "./loop-manager.js";
|
|
6
7
|
/** Returns true if the attachment name/contentType indicates an audio file. */
|
|
7
8
|
export declare function isAudioAttachment(name: string, contentType: string): boolean;
|
|
8
9
|
/** Build the prompt text for a file/document attachment, optionally with caption. */
|
|
@@ -36,6 +37,7 @@ export declare class CcDiscordBot {
|
|
|
36
37
|
private metaAgentManager;
|
|
37
38
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
38
39
|
private mcpSession?;
|
|
40
|
+
private loopManager?;
|
|
39
41
|
constructor(opts: DiscordBotOptions);
|
|
40
42
|
/** Reverse-lookup: find the channelId string for a cron-stored integer */
|
|
41
43
|
private snowflakeMap;
|
|
@@ -64,7 +66,6 @@ export declare class CcDiscordBot {
|
|
|
64
66
|
private sendToChannel;
|
|
65
67
|
/** Send to a channel by ID — used by notifier callbacks. */
|
|
66
68
|
sendToChannelById(channelId: string, text: string): Promise<void>;
|
|
67
|
-
sendAttachmentToChannelById(channelId: string, filePath: string): Promise<void>;
|
|
68
69
|
private isAllowed;
|
|
69
70
|
private handleMessage;
|
|
70
71
|
private handleVoice;
|
|
@@ -97,6 +98,22 @@ export declare class CcDiscordBot {
|
|
|
97
98
|
private writeChatMessage;
|
|
98
99
|
/** Returns the last channelId that sent a message. */
|
|
99
100
|
getLastActiveChannelId(): string | undefined;
|
|
101
|
+
/** Reverse lookup: find the Discord channelId registered for a given namespace. */
|
|
102
|
+
getChannelIdForNamespace(ns: string): string | undefined;
|
|
103
|
+
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
104
|
+
getLoopThreadId(channelId: string): string | undefined;
|
|
105
|
+
/**
|
|
106
|
+
* Post a structured eval-report embed to the loop thread for `channelId`.
|
|
107
|
+
* Also records gate failures in the LoopManager if the gate did not pass.
|
|
108
|
+
*/
|
|
109
|
+
postEvalEmbed(channelId: string, report: EvalReport): Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Create a Discord thread on `msg`, register loop state, and add reaction gates.
|
|
112
|
+
* Requires MANAGE_THREADS permission. Falls back silently on failure.
|
|
113
|
+
*/
|
|
114
|
+
private createLoopThread;
|
|
115
|
+
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
116
|
+
private handleReactionAdd;
|
|
100
117
|
/**
|
|
101
118
|
* Feed a text message into the active Claude session for the given channel.
|
|
102
119
|
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
package/dist/bot.js
CHANGED
|
@@ -13,6 +13,7 @@ import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
|
13
13
|
import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
|
|
14
14
|
import { getCurrentToken } from "./tokens.js";
|
|
15
15
|
import { writeChatLog } from "./notifier.js";
|
|
16
|
+
import { LoopManager, isGoalMessage } from "./loop-manager.js";
|
|
16
17
|
import { CronManager } from "./cron.js";
|
|
17
18
|
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
18
19
|
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
@@ -122,12 +123,14 @@ export class CcDiscordBot {
|
|
|
122
123
|
metaAgentManager;
|
|
123
124
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
124
125
|
mcpSession;
|
|
126
|
+
loopManager;
|
|
125
127
|
constructor(opts) {
|
|
126
128
|
this.opts = opts;
|
|
127
129
|
this.redis = opts.redis;
|
|
128
130
|
this.namespace = opts.namespace ?? "default";
|
|
129
131
|
if (opts.redis) {
|
|
130
132
|
this.wire = createCcWire(opts.redis);
|
|
133
|
+
this.loopManager = new LoopManager(opts.redis);
|
|
131
134
|
}
|
|
132
135
|
this.metaAgentManager = createMetaAgentManager();
|
|
133
136
|
this.client = new Client({
|
|
@@ -136,6 +139,7 @@ export class CcDiscordBot {
|
|
|
136
139
|
GatewayIntentBits.GuildMessages,
|
|
137
140
|
GatewayIntentBits.MessageContent,
|
|
138
141
|
GatewayIntentBits.DirectMessages,
|
|
142
|
+
GatewayIntentBits.GuildMessageReactions,
|
|
139
143
|
],
|
|
140
144
|
});
|
|
141
145
|
this.cron = new CronManager(opts.cwd ?? process.cwd(), (chatIdNum, prompt, _jobId, done) => {
|
|
@@ -168,6 +172,9 @@ export class CcDiscordBot {
|
|
|
168
172
|
return;
|
|
169
173
|
void this.handleSlashCommand(interaction);
|
|
170
174
|
});
|
|
175
|
+
this.client.on(Events.MessageReactionAdd, (reaction, user) => {
|
|
176
|
+
void this.handleReactionAdd(reaction, user);
|
|
177
|
+
});
|
|
171
178
|
this.client.on("error", (err) => {
|
|
172
179
|
console.error("[discord] client error:", err.message);
|
|
173
180
|
});
|
|
@@ -294,15 +301,6 @@ export class CcDiscordBot {
|
|
|
294
301
|
}
|
|
295
302
|
await this.sendToChannel(channel, text);
|
|
296
303
|
}
|
|
297
|
-
async sendAttachmentToChannelById(channelId, filePath) {
|
|
298
|
-
const channel = await this.getChannel(channelId);
|
|
299
|
-
if (!channel) {
|
|
300
|
-
console.warn(`[bot] sendAttachmentToChannelById: channel ${channelId} not found`);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
const attachment = new AttachmentBuilder(filePath, { name: basename(filePath) });
|
|
304
|
-
await channel.send({ files: [attachment] });
|
|
305
|
-
}
|
|
306
304
|
isAllowed(userId) {
|
|
307
305
|
if (!this.opts.allowedUserIds?.length)
|
|
308
306
|
return true;
|
|
@@ -374,13 +372,42 @@ export class CcDiscordBot {
|
|
|
374
372
|
return;
|
|
375
373
|
}
|
|
376
374
|
}
|
|
375
|
+
// Handle messages sent inside a loop thread — route to the running meta-agent session
|
|
376
|
+
if (msg.channel.isThread() && this.redis && this.loopManager) {
|
|
377
|
+
const parentId = msg.channel.parentId;
|
|
378
|
+
if (parentId) {
|
|
379
|
+
const loopState = this.loopManager.getState(parentId);
|
|
380
|
+
if (loopState && loopState.threadId === effectiveChannelId) {
|
|
381
|
+
const username = msg.member?.displayName ?? msg.author.username;
|
|
382
|
+
try {
|
|
383
|
+
await routeToMetaAgent(loopState.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
await msg.channel.send(`Failed to route: ${err.message}`).catch(() => { });
|
|
387
|
+
}
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
377
392
|
// Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
|
|
378
393
|
const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
|
|
379
394
|
if (mappedNs && this.redis) {
|
|
395
|
+
// If a loop is already running for this channel, point the user to the thread
|
|
396
|
+
if (this.loopManager?.isActive(effectiveChannelId)) {
|
|
397
|
+
const loopState = this.loopManager.getState(effectiveChannelId);
|
|
398
|
+
await msg.channel.send(`Loop in progress → <#${loopState.threadId}>\nSend messages in the thread to interact with the running loop.`).catch(() => { });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
380
401
|
this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
|
|
381
402
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
|
|
382
403
|
this.persistChannelMapping(effectiveChannelId, mappedNs.namespace, mappedNs.repoUrl);
|
|
383
404
|
this.startMetaAgentTyping(effectiveChannelId, msg.channel);
|
|
405
|
+
// Detect goal messages → create a thread for loop tracking
|
|
406
|
+
if (this.loopManager && msg.guild && !msg.channel.isThread()) {
|
|
407
|
+
if (isGoalMessage(text)) {
|
|
408
|
+
await this.createLoopThread(msg, effectiveChannelId, mappedNs.namespace, text);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
384
411
|
const username = msg.member?.displayName ?? msg.author.username;
|
|
385
412
|
try {
|
|
386
413
|
await routeToMetaAgent(mappedNs.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
|
|
@@ -1009,6 +1036,129 @@ export class CcDiscordBot {
|
|
|
1009
1036
|
getLastActiveChannelId() {
|
|
1010
1037
|
return this.lastActiveChannelId;
|
|
1011
1038
|
}
|
|
1039
|
+
/** Reverse lookup: find the Discord channelId registered for a given namespace. */
|
|
1040
|
+
getChannelIdForNamespace(ns) {
|
|
1041
|
+
for (const [channelId, mapping] of this.channelNamespaceMap) {
|
|
1042
|
+
if (mapping.namespace === ns)
|
|
1043
|
+
return channelId;
|
|
1044
|
+
}
|
|
1045
|
+
return undefined;
|
|
1046
|
+
}
|
|
1047
|
+
/** Return the thread ID for an active loop on `channelId`, or undefined. */
|
|
1048
|
+
getLoopThreadId(channelId) {
|
|
1049
|
+
return this.loopManager?.getThreadId(channelId);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Post a structured eval-report embed to the loop thread for `channelId`.
|
|
1053
|
+
* Also records gate failures in the LoopManager if the gate did not pass.
|
|
1054
|
+
*/
|
|
1055
|
+
async postEvalEmbed(channelId, report) {
|
|
1056
|
+
const threadId = this.loopManager?.getThreadId(channelId);
|
|
1057
|
+
if (!threadId)
|
|
1058
|
+
return;
|
|
1059
|
+
const channel = await this.getChannel(threadId);
|
|
1060
|
+
if (!channel)
|
|
1061
|
+
return;
|
|
1062
|
+
const color = report.passed ? 0x57F287 : 0xED4245; // green / red
|
|
1063
|
+
const embed = new EmbedBuilder()
|
|
1064
|
+
.setTitle(`${report.passed ? "✅" : "❌"} Gate: ${report.gate}`)
|
|
1065
|
+
.setColor(color)
|
|
1066
|
+
.addFields({ name: "Result", value: report.passed ? "PASSED" : "FAILED", inline: true }, { name: "Confidence", value: `${(report.confidence * 100).toFixed(0)}%`, inline: true }, { name: "Iteration", value: `${report.iteration} / ${report.maxIterations}`, inline: true }, { name: "Feedback", value: report.feedback || "(none)", inline: false })
|
|
1067
|
+
.setTimestamp();
|
|
1068
|
+
await channel.send({ embeds: [embed] }).catch((err) => {
|
|
1069
|
+
console.warn(`[bot] postEvalEmbed send failed:`, err.message);
|
|
1070
|
+
});
|
|
1071
|
+
if (!report.passed && this.loopManager) {
|
|
1072
|
+
await this.loopManager.addGateFailure(channelId, {
|
|
1073
|
+
gate: report.gate,
|
|
1074
|
+
feedback: report.feedback,
|
|
1075
|
+
iteration: report.iteration,
|
|
1076
|
+
confidence: report.confidence,
|
|
1077
|
+
timestamp: new Date().toISOString(),
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Create a Discord thread on `msg`, register loop state, and add reaction gates.
|
|
1083
|
+
* Requires MANAGE_THREADS permission. Falls back silently on failure.
|
|
1084
|
+
*/
|
|
1085
|
+
async createLoopThread(msg, channelId, namespace, goal) {
|
|
1086
|
+
try {
|
|
1087
|
+
const shortGoal = goal.slice(0, 48).replace(/\s+/g, " ");
|
|
1088
|
+
const threadName = `Goal: ${shortGoal}${goal.length > 48 ? "…" : ""}`;
|
|
1089
|
+
const thread = await msg.channel.threads.create({
|
|
1090
|
+
name: threadName,
|
|
1091
|
+
autoArchiveDuration: 10080, // 1 week
|
|
1092
|
+
startMessage: msg,
|
|
1093
|
+
reason: "Loop observability thread",
|
|
1094
|
+
});
|
|
1095
|
+
const firstMsg = await thread.send(`🎯 **Loop tracking thread**\n> ${goal.slice(0, 200)}\n\nReact to control the loop:\n` +
|
|
1096
|
+
`🔄 Retry current iteration ✅ Accept & exit ❌ Kill loop`);
|
|
1097
|
+
await firstMsg.react("🔄");
|
|
1098
|
+
await firstMsg.react("✅");
|
|
1099
|
+
await firstMsg.react("❌");
|
|
1100
|
+
await this.loopManager.startLoop(channelId, thread.id, firstMsg.id, namespace, goal);
|
|
1101
|
+
console.log(`[bot] loop thread created: channelId=${channelId} threadId=${thread.id}`);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
console.warn(`[bot] createLoopThread failed:`, err.message);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** Handle 🔄/✅/❌ reactions on loop gate messages. */
|
|
1108
|
+
async handleReactionAdd(reaction, user) {
|
|
1109
|
+
if (user.bot)
|
|
1110
|
+
return;
|
|
1111
|
+
if (!this.isAllowed(user.id))
|
|
1112
|
+
return;
|
|
1113
|
+
if (!this.loopManager || !this.redis)
|
|
1114
|
+
return;
|
|
1115
|
+
// Fetch full reaction object if partial (bot doesn't have it cached)
|
|
1116
|
+
let fullReaction = reaction;
|
|
1117
|
+
if (reaction.partial) {
|
|
1118
|
+
try {
|
|
1119
|
+
fullReaction = await reaction.fetch();
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const emoji = fullReaction.emoji.name;
|
|
1126
|
+
if (emoji !== "🔄" && emoji !== "✅" && emoji !== "❌")
|
|
1127
|
+
return;
|
|
1128
|
+
const messageId = fullReaction.message.id;
|
|
1129
|
+
const channelId = this.loopManager.getChannelIdByReactionMessage(messageId);
|
|
1130
|
+
if (!channelId)
|
|
1131
|
+
return;
|
|
1132
|
+
const loopState = this.loopManager.getState(channelId);
|
|
1133
|
+
if (!loopState)
|
|
1134
|
+
return;
|
|
1135
|
+
const thread = await this.getChannel(loopState.threadId);
|
|
1136
|
+
if (emoji === "🔄") {
|
|
1137
|
+
if (thread) {
|
|
1138
|
+
await thread.send(`🔄 Retry requested — re-running iteration ${loopState.iteration + 1}`).catch(() => { });
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
await routeToMetaAgent(loopState.namespace, `Please retry the previous goal: ${loopState.goal}`, this.redis);
|
|
1142
|
+
}
|
|
1143
|
+
catch (err) {
|
|
1144
|
+
console.warn(`[bot] retry routeToMetaAgent failed:`, err.message);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else if (emoji === "✅") {
|
|
1148
|
+
if (thread) {
|
|
1149
|
+
await thread.send("✅ Loop accepted — marking complete").catch(() => { });
|
|
1150
|
+
}
|
|
1151
|
+
await this.sendToChannelById(channelId, `✅ Goal completed: ${loopState.goal.slice(0, 100)}`);
|
|
1152
|
+
await this.loopManager.endLoop(channelId);
|
|
1153
|
+
}
|
|
1154
|
+
else if (emoji === "❌") {
|
|
1155
|
+
if (thread) {
|
|
1156
|
+
await thread.send("❌ Loop killed by user").catch(() => { });
|
|
1157
|
+
}
|
|
1158
|
+
await this.sendToChannelById(channelId, `❌ Loop killed: ${loopState.goal.slice(0, 100)}`);
|
|
1159
|
+
await this.loopManager.endLoop(channelId);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1012
1162
|
/**
|
|
1013
1163
|
* Feed a text message into the active Claude session for the given channel.
|
|
1014
1164
|
* Called by the notifier when a UI message arrives via Redis pub/sub.
|
|
@@ -1072,7 +1222,7 @@ export class CcDiscordBot {
|
|
|
1072
1222
|
startMetaAgentPolling() {
|
|
1073
1223
|
if (!this.wire)
|
|
1074
1224
|
return;
|
|
1075
|
-
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()));
|
|
1225
|
+
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()).map((v) => v.namespace));
|
|
1076
1226
|
}
|
|
1077
1227
|
stop() {
|
|
1078
1228
|
for (const [key, session] of this.sessions) {
|
package/dist/index.js
CHANGED
|
@@ -142,7 +142,7 @@ const bot = new CcDiscordBot({
|
|
|
142
142
|
namespace,
|
|
143
143
|
registerRoutedChannelId: (ns, channelId) => notifier.registerRoutedChannelId(ns, channelId),
|
|
144
144
|
});
|
|
145
|
-
const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n));
|
|
145
|
+
const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n), (ns) => bot.getChannelIdForNamespace(ns));
|
|
146
146
|
console.log(`[notifier] started for namespace=${namespace} notifyChannelId=${notifyChannelId ?? "dynamic"}`);
|
|
147
147
|
// Restore persisted channel→namespace mappings so routing survives restarts
|
|
148
148
|
bot.loadChannelMappings().catch((err) => {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop observability for cc-discord meta-agent channels.
|
|
3
|
+
*
|
|
4
|
+
* When a user sends a goal-oriented message to a meta-agent channel the bot
|
|
5
|
+
* creates a Discord thread on that message, tracks iteration state in Redis,
|
|
6
|
+
* and lets human operators steer the loop via 🔄/✅/❌ reactions.
|
|
7
|
+
*
|
|
8
|
+
* Redis layout:
|
|
9
|
+
* cca:discord:loop:{channelId}:{threadId} HASH TTL 24h
|
|
10
|
+
* goal → string
|
|
11
|
+
* namespace → string
|
|
12
|
+
* iteration → stringified number
|
|
13
|
+
* max_iterations → stringified number
|
|
14
|
+
* thread_id → string
|
|
15
|
+
* gate_failures → JSON array of GateFailure
|
|
16
|
+
*/
|
|
17
|
+
import type { Redis } from "ioredis";
|
|
18
|
+
export interface GateFailure {
|
|
19
|
+
gate: string;
|
|
20
|
+
feedback: string;
|
|
21
|
+
iteration: number;
|
|
22
|
+
confidence: number;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
}
|
|
25
|
+
export interface LoopState {
|
|
26
|
+
channelId: string;
|
|
27
|
+
threadId: string;
|
|
28
|
+
/** ID of the first message posted inside the thread (reactions live here). */
|
|
29
|
+
goalMessageId: string;
|
|
30
|
+
namespace: string;
|
|
31
|
+
goal: string;
|
|
32
|
+
iteration: number;
|
|
33
|
+
maxIterations: number;
|
|
34
|
+
gateFailures: GateFailure[];
|
|
35
|
+
}
|
|
36
|
+
export interface EvalReport {
|
|
37
|
+
gate: string;
|
|
38
|
+
passed: boolean;
|
|
39
|
+
feedback: string;
|
|
40
|
+
iteration: number;
|
|
41
|
+
maxIterations: number;
|
|
42
|
+
confidence: number;
|
|
43
|
+
}
|
|
44
|
+
/** Return true when `text` looks like an action goal rather than a question or chat. */
|
|
45
|
+
export declare function isGoalMessage(text: string): boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
48
|
+
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseEvalReport(raw: string): EvalReport | null;
|
|
51
|
+
export declare class LoopManager {
|
|
52
|
+
private redis;
|
|
53
|
+
/** channelId → LoopState */
|
|
54
|
+
private activeLoops;
|
|
55
|
+
/** reactionMessageId → channelId — for O(1) lookup on reaction events */
|
|
56
|
+
private reactionMessageMap;
|
|
57
|
+
constructor(redis: Redis);
|
|
58
|
+
startLoop(channelId: string, threadId: string, goalMessageId: string, namespace: string, goal: string, maxIterations?: number): Promise<LoopState>;
|
|
59
|
+
isActive(channelId: string): boolean;
|
|
60
|
+
getState(channelId: string): LoopState | undefined;
|
|
61
|
+
getThreadId(channelId: string): string | undefined;
|
|
62
|
+
/** Look up the main channel ID from a reaction message ID. */
|
|
63
|
+
getChannelIdByReactionMessage(messageId: string): string | undefined;
|
|
64
|
+
addGateFailure(channelId: string, failure: GateFailure): Promise<void>;
|
|
65
|
+
endLoop(channelId: string): Promise<void>;
|
|
66
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop observability for cc-discord meta-agent channels.
|
|
3
|
+
*
|
|
4
|
+
* When a user sends a goal-oriented message to a meta-agent channel the bot
|
|
5
|
+
* creates a Discord thread on that message, tracks iteration state in Redis,
|
|
6
|
+
* and lets human operators steer the loop via 🔄/✅/❌ reactions.
|
|
7
|
+
*
|
|
8
|
+
* Redis layout:
|
|
9
|
+
* cca:discord:loop:{channelId}:{threadId} HASH TTL 24h
|
|
10
|
+
* goal → string
|
|
11
|
+
* namespace → string
|
|
12
|
+
* iteration → stringified number
|
|
13
|
+
* max_iterations → stringified number
|
|
14
|
+
* thread_id → string
|
|
15
|
+
* gate_failures → JSON array of GateFailure
|
|
16
|
+
*/
|
|
17
|
+
const LOOP_TTL = 86_400; // 24h in seconds
|
|
18
|
+
function loopKey(channelId, threadId) {
|
|
19
|
+
return `cca:discord:loop:${channelId}:${threadId}`;
|
|
20
|
+
}
|
|
21
|
+
// Action-verb heuristic: messages that start with one of these words are treated as goals.
|
|
22
|
+
const GOAL_RE = /^(create|build|write|implement|fix|add|make|generate|refactor|set\s+up|update|deploy|run|test|check|migrate|convert|analyze|summarize|extract|draft|scaffold|configure|install|delete|remove|optimize|debug|review|audit|plan|design|port|bump|release)\b/i;
|
|
23
|
+
/** Return true when `text` looks like an action goal rather than a question or chat. */
|
|
24
|
+
export function isGoalMessage(text) {
|
|
25
|
+
return GOAL_RE.test(text.trim());
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Parse an `eval_report` object embedded in a raw notification JSON string.
|
|
29
|
+
* Returns null when the field is absent, malformed, or the input is not JSON.
|
|
30
|
+
*/
|
|
31
|
+
export function parseEvalReport(raw) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
const r = parsed.eval_report;
|
|
35
|
+
if (!r || typeof r.gate !== "string" || typeof r.passed !== "boolean")
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
gate: r.gate,
|
|
39
|
+
passed: r.passed,
|
|
40
|
+
feedback: typeof r.feedback === "string" ? r.feedback : "",
|
|
41
|
+
iteration: typeof r.iteration === "number" ? r.iteration : 0,
|
|
42
|
+
maxIterations: typeof r.max_iterations === "number" ? r.max_iterations : 0,
|
|
43
|
+
confidence: typeof r.confidence === "number" ? r.confidence : 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class LoopManager {
|
|
51
|
+
redis;
|
|
52
|
+
/** channelId → LoopState */
|
|
53
|
+
activeLoops = new Map();
|
|
54
|
+
/** reactionMessageId → channelId — for O(1) lookup on reaction events */
|
|
55
|
+
reactionMessageMap = new Map();
|
|
56
|
+
constructor(redis) {
|
|
57
|
+
this.redis = redis;
|
|
58
|
+
}
|
|
59
|
+
async startLoop(channelId, threadId, goalMessageId, namespace, goal, maxIterations = 10) {
|
|
60
|
+
const state = {
|
|
61
|
+
channelId,
|
|
62
|
+
threadId,
|
|
63
|
+
goalMessageId,
|
|
64
|
+
namespace,
|
|
65
|
+
goal,
|
|
66
|
+
iteration: 0,
|
|
67
|
+
maxIterations,
|
|
68
|
+
gateFailures: [],
|
|
69
|
+
};
|
|
70
|
+
this.activeLoops.set(channelId, state);
|
|
71
|
+
this.reactionMessageMap.set(goalMessageId, channelId);
|
|
72
|
+
const key = loopKey(channelId, threadId);
|
|
73
|
+
await this.redis.hset(key, {
|
|
74
|
+
goal,
|
|
75
|
+
namespace,
|
|
76
|
+
iteration: "0",
|
|
77
|
+
max_iterations: String(maxIterations),
|
|
78
|
+
thread_id: threadId,
|
|
79
|
+
gate_failures: "[]",
|
|
80
|
+
});
|
|
81
|
+
await this.redis.expire(key, LOOP_TTL);
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
isActive(channelId) {
|
|
85
|
+
return this.activeLoops.has(channelId);
|
|
86
|
+
}
|
|
87
|
+
getState(channelId) {
|
|
88
|
+
return this.activeLoops.get(channelId);
|
|
89
|
+
}
|
|
90
|
+
getThreadId(channelId) {
|
|
91
|
+
return this.activeLoops.get(channelId)?.threadId;
|
|
92
|
+
}
|
|
93
|
+
/** Look up the main channel ID from a reaction message ID. */
|
|
94
|
+
getChannelIdByReactionMessage(messageId) {
|
|
95
|
+
return this.reactionMessageMap.get(messageId);
|
|
96
|
+
}
|
|
97
|
+
async addGateFailure(channelId, failure) {
|
|
98
|
+
const state = this.activeLoops.get(channelId);
|
|
99
|
+
if (!state)
|
|
100
|
+
return;
|
|
101
|
+
state.gateFailures.push(failure);
|
|
102
|
+
state.iteration++;
|
|
103
|
+
const key = loopKey(channelId, state.threadId);
|
|
104
|
+
await this.redis.hset(key, {
|
|
105
|
+
iteration: String(state.iteration),
|
|
106
|
+
gate_failures: JSON.stringify(state.gateFailures),
|
|
107
|
+
});
|
|
108
|
+
await this.redis.expire(key, LOOP_TTL);
|
|
109
|
+
}
|
|
110
|
+
async endLoop(channelId) {
|
|
111
|
+
const state = this.activeLoops.get(channelId);
|
|
112
|
+
if (state) {
|
|
113
|
+
this.reactionMessageMap.delete(state.goalMessageId);
|
|
114
|
+
await this.redis.del(loopKey(channelId, state.threadId));
|
|
115
|
+
}
|
|
116
|
+
this.activeLoops.delete(channelId);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -36,14 +36,10 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
|
|
|
36
36
|
* Returns a Promise that resolves when the process exits.
|
|
37
37
|
*/
|
|
38
38
|
export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
|
|
39
|
-
export interface NamespaceEntry {
|
|
40
|
-
namespace: string;
|
|
41
|
-
repoUrl: string;
|
|
42
|
-
}
|
|
43
39
|
export interface MetaAgentManager {
|
|
44
40
|
ensureWorkspace: (ns: string, repoUrl: string) => Promise<void>;
|
|
45
41
|
injectMcp: (ns: string, token: string) => void;
|
|
46
|
-
startPolling: (wire: Wire,
|
|
42
|
+
startPolling: (wire: Wire, getNamespaces: () => string[]) => void;
|
|
47
43
|
stop: () => void;
|
|
48
44
|
}
|
|
49
45
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 4. spawnSession: claude --continue -p "{message}" pipes stdout → wire.discord.publishOutgoing
|
|
9
9
|
*/
|
|
10
10
|
import { spawn, execSync } from "child_process";
|
|
11
|
-
import { existsSync, mkdirSync,
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
14
|
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, discordMetaInputKey, } from "@gonzih/cc-wire";
|
|
@@ -59,55 +59,25 @@ export function injectMcp(ns, wsPath, token) {
|
|
|
59
59
|
const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
|
|
60
60
|
const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "";
|
|
61
61
|
const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
62
|
+
const config = {
|
|
63
|
+
mcpServers: {
|
|
64
|
+
"cc-agent": {
|
|
65
|
+
command: "/opt/homebrew/bin/npx",
|
|
66
|
+
args: ["-y", "--prefer-online", "@gonzih/cc-agent"],
|
|
67
|
+
env: {
|
|
68
|
+
CC_AGENT_NAMESPACE: ns,
|
|
69
|
+
CWD: wsPath,
|
|
70
|
+
CLAUDE_CODE_OAUTH_TOKEN: token,
|
|
71
|
+
CLAUDE_TOKENS: token,
|
|
72
|
+
CC_AGENT_TRUSTED_OWNERS: trustedOwners,
|
|
73
|
+
PATH: systemPath,
|
|
74
|
+
npm_config_cache: npmCache,
|
|
75
|
+
},
|
|
74
76
|
},
|
|
75
77
|
},
|
|
76
78
|
};
|
|
77
|
-
// Merge extra MCP servers from ~/.config/cc-discord-mcp.json (credentials file, not in source)
|
|
78
|
-
const extraMcpPath = join(homedir(), ".config", "cc-discord-mcp.json");
|
|
79
|
-
if (existsSync(extraMcpPath)) {
|
|
80
|
-
try {
|
|
81
|
-
const extra = JSON.parse(readFileSync(extraMcpPath, "utf8"));
|
|
82
|
-
Object.assign(servers, extra);
|
|
83
|
-
console.log(`[meta-agent-manager] merged extra MCP servers from ${extraMcpPath}: ${Object.keys(extra).join(", ")}`);
|
|
84
|
-
}
|
|
85
|
-
catch (err) {
|
|
86
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
87
|
-
console.warn(`[meta-agent-manager] failed to read extra MCP config: ${msg}`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
// Also check env vars as fallback for gmail and github
|
|
91
|
-
const gmailEmail = process.env.GMAIL_EMAIL;
|
|
92
|
-
const gmailPass = process.env.GMAIL_APP_PASSWORD;
|
|
93
|
-
if (gmailEmail && gmailPass && !servers["gmail-personal"]) {
|
|
94
|
-
servers["gmail-personal"] = {
|
|
95
|
-
command: "/opt/homebrew/bin/npx",
|
|
96
|
-
args: ["-y", "gmail-mcp-imap"],
|
|
97
|
-
env: { GMAIL_EMAIL: gmailEmail, GMAIL_APP_PASSWORD: gmailPass, npm_config_cache: npmCache },
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
const ghToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
101
|
-
if (ghToken && !servers["github"]) {
|
|
102
|
-
servers["github"] = {
|
|
103
|
-
command: "docker",
|
|
104
|
-
args: ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
|
|
105
|
-
env: { GITHUB_PERSONAL_ACCESS_TOKEN: ghToken },
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
const config = { mcpServers: servers };
|
|
109
79
|
writeFileSync(mcpPath, JSON.stringify(config, null, 2), "utf8");
|
|
110
|
-
console.log(`[meta-agent-manager] injected MCP config for namespace=${ns}
|
|
80
|
+
console.log(`[meta-agent-manager] injected MCP config for namespace=${ns}`);
|
|
111
81
|
}
|
|
112
82
|
/**
|
|
113
83
|
* Resolve claude binary — same logic as claude.ts resolveClaude.
|
|
@@ -213,14 +183,14 @@ export function createMetaAgentManager() {
|
|
|
213
183
|
const wsPath = workspacePath(ns);
|
|
214
184
|
injectMcp(ns, wsPath, token);
|
|
215
185
|
},
|
|
216
|
-
startPolling(wire,
|
|
186
|
+
startPolling(wire, getNamespaces) {
|
|
217
187
|
if (pollInterval)
|
|
218
188
|
return; // already running
|
|
219
189
|
pollInterval = setInterval(() => {
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
190
|
+
const namespaces = getNamespaces();
|
|
191
|
+
if (namespaces.length === 0)
|
|
222
192
|
return;
|
|
223
|
-
for (const
|
|
193
|
+
for (const ns of namespaces) {
|
|
224
194
|
if (activeNamespaces.has(ns))
|
|
225
195
|
continue;
|
|
226
196
|
wire.discord.dequeue(ns)
|
|
@@ -258,24 +228,6 @@ export function createMetaAgentManager() {
|
|
|
258
228
|
});
|
|
259
229
|
return;
|
|
260
230
|
}
|
|
261
|
-
// Ensure workspace exists before spawning — clone happens once, no-op on repeat.
|
|
262
|
-
try {
|
|
263
|
-
await ensureWorkspace(ns, repoUrl);
|
|
264
|
-
injectMcp(ns, workspacePath(ns), token);
|
|
265
|
-
}
|
|
266
|
-
catch (wsErr) {
|
|
267
|
-
const msg = wsErr instanceof Error ? wsErr.message : String(wsErr);
|
|
268
|
-
console.error(`[meta-agent-manager] workspace setup failed (ns=${ns}):`, msg);
|
|
269
|
-
activeNamespaces.delete(ns);
|
|
270
|
-
await wire.discord.setStatus(ns, {
|
|
271
|
-
namespace: ns,
|
|
272
|
-
status: "idle",
|
|
273
|
-
isTyping: false,
|
|
274
|
-
turnCount: 0,
|
|
275
|
-
updatedAt: new Date().toISOString(),
|
|
276
|
-
}).catch(() => { });
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
231
|
spawnSession(ns, content, token, wire)
|
|
280
232
|
.catch((err) => {
|
|
281
233
|
console.error(`[meta-agent-manager] session error (ns=${ns}):`, err.message);
|
package/dist/notifier.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { Redis } from "ioredis";
|
|
14
|
+
import { type EvalReport } from "./loop-manager.js";
|
|
14
15
|
import type { CcDiscordBot } from "./bot.js";
|
|
15
16
|
export interface ChatMessage {
|
|
16
17
|
id: string;
|
|
@@ -23,6 +24,8 @@ export interface ChatMessage {
|
|
|
23
24
|
export interface ParsedNotification {
|
|
24
25
|
text: string;
|
|
25
26
|
chatId?: number;
|
|
27
|
+
/** Populated when the notification JSON contains an `eval_report` object. */
|
|
28
|
+
evalReport?: EvalReport;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* Parse a notification payload.
|
|
@@ -40,10 +43,13 @@ export declare function parseNotification(raw: string): ParsedNotification | nul
|
|
|
40
43
|
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
41
44
|
/**
|
|
42
45
|
* Resolve the target Discord channelId for a notification.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
46
|
+
* Priority:
|
|
47
|
+
* 1. chatId → reverseSnowflakeLookup (originating channel from the notification payload)
|
|
48
|
+
* 2. ns → getChannelIdForNamespace (registered Discord channel for this namespace)
|
|
49
|
+
* 3. notifyChannelId (static env var — may be stale/dead)
|
|
50
|
+
* 4. getActiveChannelId (last channel that sent a message)
|
|
45
51
|
*/
|
|
46
|
-
export declare function resolveNotifyChannel(chatId: number | undefined, notifyChannelId: string | null, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined): string | undefined;
|
|
52
|
+
export declare function resolveNotifyChannel(chatId: number | undefined, notifyChannelId: string | null, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined, ns?: string, getChannelIdForNamespace?: (ns: string) => string | undefined): string | undefined;
|
|
47
53
|
export interface NotifierHandle {
|
|
48
54
|
/**
|
|
49
55
|
* Register the originating Discord channel ID for a routed namespace.
|
|
@@ -57,13 +63,14 @@ export interface NotifierHandle {
|
|
|
57
63
|
/**
|
|
58
64
|
* Start the Discord notifier.
|
|
59
65
|
*
|
|
60
|
-
* @param bot
|
|
61
|
-
* @param notifyChannelId
|
|
62
|
-
* @param namespace
|
|
63
|
-
* @param redis
|
|
64
|
-
* @param handleUserMessage
|
|
65
|
-
* @param forwardNotification
|
|
66
|
-
* @param getActiveChannelId
|
|
67
|
-
* @param reverseSnowflakeLookup
|
|
66
|
+
* @param bot - CcDiscordBot instance (for sending messages)
|
|
67
|
+
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
68
|
+
* @param namespace - primary namespace (used to build Redis channel names)
|
|
69
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
70
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
71
|
+
* @param forwardNotification - Optional callback to forward job notifications
|
|
72
|
+
* @param getActiveChannelId - Optional callback to resolve channelId dynamically
|
|
73
|
+
* @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
|
|
74
|
+
* @param getChannelIdForNamespace - Optional callback to resolve a namespace to its registered Discord channelId
|
|
68
75
|
*/
|
|
69
|
-
export declare function startNotifier(bot: CcDiscordBot, notifyChannelId: string | null, namespace: string, redis: Redis, handleUserMessage?: (channelId: string, text: string) => void, forwardNotification?: (channelId: string, text: string) => void, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined): NotifierHandle;
|
|
76
|
+
export declare function startNotifier(bot: CcDiscordBot, notifyChannelId: string | null, namespace: string, redis: Redis, handleUserMessage?: (channelId: string, text: string) => void, forwardNotification?: (channelId: string, text: string) => void, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined, getChannelIdForNamespace?: (ns: string) => string | undefined): NotifierHandle;
|
package/dist/notifier.js
CHANGED
|
@@ -11,22 +11,8 @@
|
|
|
11
11
|
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
|
|
14
|
-
import { existsSync, statSync } from "fs";
|
|
15
14
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
16
|
-
|
|
17
|
-
const MAX_DISCORD_BYTES = 25 * 1024 * 1024; // 25 MB Discord file limit
|
|
18
|
-
/** Extract /tmp/ or /var/folders/ file paths mentioned in text that actually exist on disk. */
|
|
19
|
-
function extractAttachablePaths(text) {
|
|
20
|
-
const pattern = /(?:^|[\s`'"(])(\/(?:tmp|var\/folders)\/[\w.\-/]+\.[\w]{1,10})(?:[\s`'")\n]|$)/gm;
|
|
21
|
-
const quoted = /"(\/(?:tmp|var\/folders)\/[^"]+\.[a-zA-Z0-9]{1,10})"|'(\/(?:tmp|var\/folders)\/[^']+\.[a-zA-Z0-9]{1,10})'/g;
|
|
22
|
-
const candidates = new Set();
|
|
23
|
-
let m;
|
|
24
|
-
while ((m = pattern.exec(text)) !== null)
|
|
25
|
-
candidates.add(m[1]);
|
|
26
|
-
while ((m = quoted.exec(text)) !== null)
|
|
27
|
-
candidates.add(m[1] ?? m[2]);
|
|
28
|
-
return [...candidates].filter(p => SAFE_DIRS.some(d => p.startsWith(d)) && existsSync(p));
|
|
29
|
-
}
|
|
15
|
+
import { parseEvalReport } from "./loop-manager.js";
|
|
30
16
|
function log(level, ...args) {
|
|
31
17
|
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
32
18
|
fn("[notifier]", ...args);
|
|
@@ -76,12 +62,14 @@ export function parseNotification(raw) {
|
|
|
76
62
|
catch {
|
|
77
63
|
return { text };
|
|
78
64
|
}
|
|
65
|
+
// Parse eval_report if present — this field is non-standard and not in NotificationPayload type
|
|
66
|
+
const evalReport = parseEvalReport(raw);
|
|
79
67
|
if (!driver)
|
|
80
|
-
return { text, chatId };
|
|
68
|
+
return { text, chatId, evalReport: evalReport ?? undefined };
|
|
81
69
|
const shortModel = shortenModelName(model ?? "", driver);
|
|
82
70
|
const badge = shortModel ? `${driver}:${shortModel}` : driver;
|
|
83
71
|
const costStr = cost != null ? ` cost: $${cost.toFixed(3)}` : "";
|
|
84
|
-
return { text: `${text}\n[${badge}]${costStr}`, chatId };
|
|
72
|
+
return { text: `${text}\n[${badge}]${costStr}`, chatId, evalReport: evalReport ?? undefined };
|
|
85
73
|
}
|
|
86
74
|
/**
|
|
87
75
|
* Write a message to the chat log in Redis.
|
|
@@ -104,30 +92,39 @@ export function writeChatLog(redis, namespace, msg) {
|
|
|
104
92
|
}
|
|
105
93
|
/**
|
|
106
94
|
* Resolve the target Discord channelId for a notification.
|
|
107
|
-
*
|
|
108
|
-
*
|
|
95
|
+
* Priority:
|
|
96
|
+
* 1. chatId → reverseSnowflakeLookup (originating channel from the notification payload)
|
|
97
|
+
* 2. ns → getChannelIdForNamespace (registered Discord channel for this namespace)
|
|
98
|
+
* 3. notifyChannelId (static env var — may be stale/dead)
|
|
99
|
+
* 4. getActiveChannelId (last channel that sent a message)
|
|
109
100
|
*/
|
|
110
|
-
export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
|
|
101
|
+
export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace) {
|
|
111
102
|
if (chatId != null && reverseSnowflakeLookup) {
|
|
112
103
|
const resolved = reverseSnowflakeLookup(chatId);
|
|
113
104
|
if (resolved)
|
|
114
105
|
return resolved;
|
|
115
106
|
}
|
|
107
|
+
if (ns && getChannelIdForNamespace) {
|
|
108
|
+
const resolved = getChannelIdForNamespace(ns);
|
|
109
|
+
if (resolved)
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
116
112
|
return notifyChannelId ?? getActiveChannelId?.();
|
|
117
113
|
}
|
|
118
114
|
/**
|
|
119
115
|
* Start the Discord notifier.
|
|
120
116
|
*
|
|
121
|
-
* @param bot
|
|
122
|
-
* @param notifyChannelId
|
|
123
|
-
* @param namespace
|
|
124
|
-
* @param redis
|
|
125
|
-
* @param handleUserMessage
|
|
126
|
-
* @param forwardNotification
|
|
127
|
-
* @param getActiveChannelId
|
|
128
|
-
* @param reverseSnowflakeLookup
|
|
117
|
+
* @param bot - CcDiscordBot instance (for sending messages)
|
|
118
|
+
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
119
|
+
* @param namespace - primary namespace (used to build Redis channel names)
|
|
120
|
+
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
121
|
+
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
122
|
+
* @param forwardNotification - Optional callback to forward job notifications
|
|
123
|
+
* @param getActiveChannelId - Optional callback to resolve channelId dynamically
|
|
124
|
+
* @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
|
|
125
|
+
* @param getChannelIdForNamespace - Optional callback to resolve a namespace to its registered Discord channelId
|
|
129
126
|
*/
|
|
130
|
-
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup) {
|
|
127
|
+
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup, getChannelIdForNamespace) {
|
|
131
128
|
const wire = createCcWire(redis);
|
|
132
129
|
// Per-namespace channelId registry — maps routed namespace → Discord channelId
|
|
133
130
|
const routedChannelIds = new Map();
|
|
@@ -152,8 +149,8 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
152
149
|
if (subscribedNamespaces.has(ns))
|
|
153
150
|
return;
|
|
154
151
|
subscribedNamespaces.add(ns);
|
|
155
|
-
const notifyCh = discordNotify(ns);
|
|
156
|
-
const legacyNotifyCh = notifyChannel(ns);
|
|
152
|
+
const notifyCh = discordNotify(ns);
|
|
153
|
+
const legacyNotifyCh = notifyChannel(ns);
|
|
157
154
|
const incomingCh = chatIncomingChannel(ns);
|
|
158
155
|
channelToNamespace.set(notifyCh, ns);
|
|
159
156
|
channelToNamespace.set(legacyNotifyCh, ns);
|
|
@@ -166,13 +163,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
166
163
|
log("info", `subscribed to ${notifyCh}`);
|
|
167
164
|
}
|
|
168
165
|
});
|
|
169
|
-
// Also subscribe to legacy cc-agent notification channel
|
|
170
166
|
sub.subscribe(legacyNotifyCh, (err) => {
|
|
171
167
|
if (err) {
|
|
172
168
|
log("error", `subscribe ${legacyNotifyCh} failed:`, err.message);
|
|
173
169
|
}
|
|
174
170
|
else {
|
|
175
|
-
log("info", `subscribed to ${legacyNotifyCh}
|
|
171
|
+
log("info", `subscribed to ${legacyNotifyCh}`);
|
|
176
172
|
}
|
|
177
173
|
});
|
|
178
174
|
sub.subscribe(incomingCh, (err) => {
|
|
@@ -208,35 +204,17 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
208
204
|
const buf = metaAgentBuffers.get(ns);
|
|
209
205
|
if (!buf || !buf.text.trim())
|
|
210
206
|
return;
|
|
211
|
-
const
|
|
212
|
-
const text = `← [${ns}] ` + rawText;
|
|
207
|
+
const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
|
|
213
208
|
buf.text = "";
|
|
214
209
|
buf.timer = null;
|
|
210
|
+
// During an active loop, route meta-agent output to the thread rather than main channel
|
|
211
|
+
const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
|
|
215
212
|
const chunks = splitLongMessage(text);
|
|
216
213
|
for (const chunk of chunks) {
|
|
217
|
-
bot.sendToChannelById(
|
|
214
|
+
bot.sendToChannelById(deliverTo, chunk).catch((err) => {
|
|
218
215
|
log("warn", `meta-agent flush sendToChannelById failed (ns=${ns}):`, err.message);
|
|
219
216
|
});
|
|
220
217
|
}
|
|
221
|
-
// Attach any /tmp/ files mentioned in the response
|
|
222
|
-
const paths = extractAttachablePaths(rawText);
|
|
223
|
-
for (const filePath of paths) {
|
|
224
|
-
let size;
|
|
225
|
-
try {
|
|
226
|
-
size = statSync(filePath).size;
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
if (size > MAX_DISCORD_BYTES) {
|
|
232
|
-
bot.sendToChannelById(targetChannelId, `File too large for Discord (${(size / 1024 / 1024).toFixed(1)} MB): ${filePath}`).catch(() => { });
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
log("info", `attaching file to Discord (ns=${ns}): ${filePath}`);
|
|
236
|
-
bot.sendAttachmentToChannelById(targetChannelId, filePath).catch((err) => {
|
|
237
|
-
log("warn", `attachment send failed (ns=${ns}, path=${filePath}):`, err.message);
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
218
|
}
|
|
241
219
|
sub.on("pmessage", (pattern, channel, message) => {
|
|
242
220
|
void pattern;
|
|
@@ -303,16 +281,25 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
303
281
|
const notification = parseNotification(raw);
|
|
304
282
|
if (notification === null)
|
|
305
283
|
continue; // routing excludes discord
|
|
306
|
-
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup
|
|
284
|
+
// Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup,
|
|
285
|
+
// then namespace → channelId lookup, then notifyChannelId / active channel.
|
|
307
286
|
// Routed namespaces: always deliver to the registered Discord channelId — no leakage.
|
|
308
|
-
const
|
|
309
|
-
? (resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) ?? targetChannelId)
|
|
287
|
+
const mainChannelId = ns === namespace
|
|
288
|
+
? (resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace) ?? targetChannelId)
|
|
310
289
|
: targetChannelId;
|
|
290
|
+
// If a loop is active for this channel, route to its thread
|
|
291
|
+
const destChannelId = bot.getLoopThreadId(mainChannelId) ?? mainChannelId;
|
|
292
|
+
// When an eval report is embedded, post a structured embed to the thread
|
|
293
|
+
if (notification.evalReport) {
|
|
294
|
+
bot.postEvalEmbed(mainChannelId, notification.evalReport).catch((err) => {
|
|
295
|
+
log("warn", `postEvalEmbed failed (ns=${ns}):`, err.message);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
311
298
|
bot.sendToChannelById(destChannelId, notification.text).catch((err) => {
|
|
312
299
|
log("warn", `notify list send failed (ns=${ns}):`, err.message);
|
|
313
300
|
});
|
|
314
301
|
if (forwardNotification) {
|
|
315
|
-
forwardNotification(
|
|
302
|
+
forwardNotification(mainChannelId, notification.text);
|
|
316
303
|
}
|
|
317
304
|
}
|
|
318
305
|
if (remaining > 0) {
|
|
@@ -322,8 +309,8 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
322
309
|
}
|
|
323
310
|
};
|
|
324
311
|
const pollNotifyList = async () => {
|
|
325
|
-
// Primary namespace
|
|
326
|
-
const primaryTargetId = notifyChannelId ?? getActiveChannelId?.();
|
|
312
|
+
// Primary namespace: prefer registered channel for this namespace, then env var, then active channel
|
|
313
|
+
const primaryTargetId = getChannelIdForNamespace?.(namespace) ?? notifyChannelId ?? getActiveChannelId?.();
|
|
327
314
|
if (primaryTargetId != null) {
|
|
328
315
|
await pollOneNamespace(namespace, primaryTargetId);
|
|
329
316
|
}
|
|
@@ -344,27 +331,35 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
344
331
|
return;
|
|
345
332
|
const isPrimary = ns === namespace;
|
|
346
333
|
const notifyCh = discordNotify(ns);
|
|
334
|
+
const legacyNotifyCh = notifyChannel(ns);
|
|
347
335
|
const incomingCh = chatIncomingChannel(ns);
|
|
348
|
-
if (channel === notifyCh) {
|
|
336
|
+
if (channel === notifyCh || channel === legacyNotifyCh) {
|
|
349
337
|
const notification = parseNotification(message);
|
|
350
338
|
if (notification === null)
|
|
351
339
|
return; // routing excludes discord
|
|
352
|
-
let
|
|
340
|
+
let mainChannelId;
|
|
353
341
|
if (isPrimary) {
|
|
354
|
-
|
|
342
|
+
mainChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace);
|
|
355
343
|
}
|
|
356
344
|
else {
|
|
357
345
|
// For routed namespaces, only use the registered channelId — no fallback to primary
|
|
358
|
-
|
|
346
|
+
mainChannelId = notification.chatId != null && reverseSnowflakeLookup
|
|
359
347
|
? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
|
|
360
348
|
: routedChannelIds.get(ns);
|
|
361
349
|
}
|
|
362
|
-
if (
|
|
363
|
-
|
|
350
|
+
if (mainChannelId != null) {
|
|
351
|
+
// If a loop is active, route notification text to the thread
|
|
352
|
+
const deliverTo = bot.getLoopThreadId(mainChannelId) ?? mainChannelId;
|
|
353
|
+
if (notification.evalReport) {
|
|
354
|
+
bot.postEvalEmbed(mainChannelId, notification.evalReport).catch((err) => {
|
|
355
|
+
log("warn", `postEvalEmbed failed (ns=${ns}):`, err.message);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
bot.sendToChannelById(deliverTo, notification.text).catch((err) => {
|
|
364
359
|
log("warn", `notify send failed (ns=${ns}):`, err.message);
|
|
365
360
|
});
|
|
366
361
|
if (forwardNotification) {
|
|
367
|
-
forwardNotification(
|
|
362
|
+
forwardNotification(mainChannelId, notification.text);
|
|
368
363
|
}
|
|
369
364
|
}
|
|
370
365
|
else {
|