@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 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, getNamespaceEntries: () => NamespaceEntry[]) => void;
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, readFileSync, writeFileSync } from "fs";
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 servers = {
63
- "cc-agent": {
64
- command: "/opt/homebrew/bin/npx",
65
- args: ["-y", "--prefer-online", "@gonzih/cc-agent"],
66
- env: {
67
- CC_AGENT_NAMESPACE: ns,
68
- CWD: wsPath,
69
- CLAUDE_CODE_OAUTH_TOKEN: token,
70
- CLAUDE_TOKENS: token,
71
- CC_AGENT_TRUSTED_OWNERS: trustedOwners,
72
- PATH: systemPath,
73
- npm_config_cache: npmCache,
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} (servers: ${Object.keys(servers).join(", ")})`);
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, getNamespaceEntries) {
186
+ startPolling(wire, getNamespaces) {
217
187
  if (pollInterval)
218
188
  return; // already running
219
189
  pollInterval = setInterval(() => {
220
- const entries = getNamespaceEntries();
221
- if (entries.length === 0)
190
+ const namespaces = getNamespaces();
191
+ if (namespaces.length === 0)
222
192
  return;
223
- for (const { namespace: ns, repoUrl } of entries) {
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);
@@ -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
- * When chatId is set and a reverse-lookup function is available, prefer the originating channel.
44
- * Falls back to notifyChannelId, then getActiveChannelId.
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 - CcDiscordBot instance (for sending messages)
61
- * @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
62
- * @param namespace - primary namespace (used to build Redis channel names)
63
- * @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
64
- * @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
65
- * @param forwardNotification - Optional callback to forward job notifications
66
- * @param getActiveChannelId - Optional callback to resolve channelId dynamically
67
- * @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
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
- const SAFE_DIRS = ["/tmp/", "/var/folders/"];
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
- * When chatId is set and a reverse-lookup function is available, prefer the originating channel.
108
- * Falls back to notifyChannelId, then getActiveChannelId.
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 - CcDiscordBot instance (for sending messages)
122
- * @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
123
- * @param namespace - primary namespace (used to build Redis channel names)
124
- * @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
125
- * @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
126
- * @param forwardNotification - Optional callback to forward job notifications
127
- * @param getActiveChannelId - Optional callback to resolve channelId dynamically
128
- * @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
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); // cca:discord:notify:{ns} — new cc-wire path
156
- const legacyNotifyCh = notifyChannel(ns); // cca:notify:{ns} — cc-agent still publishes here
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} (legacy cc-agent)`);
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 rawText = stripAnsi(buf.text.trim());
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(targetChannelId, chunk).catch((err) => {
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 destChannelId = ns === namespace
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(destChannelId, notification.text);
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 targetId;
340
+ let mainChannelId;
353
341
  if (isPrimary) {
354
- targetId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup);
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
- targetId = notification.chatId != null && reverseSnowflakeLookup
346
+ mainChannelId = notification.chatId != null && reverseSnowflakeLookup
359
347
  ? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
360
348
  : routedChannelIds.get(ns);
361
349
  }
362
- if (targetId != null) {
363
- bot.sendToChannelById(targetId, notification.text).catch((err) => {
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(targetId, notification.text);
362
+ forwardNotification(mainChannelId, notification.text);
368
363
  }
369
364
  }
370
365
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {