@gonzih/cc-discord 0.1.8 → 0.1.10

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
@@ -37,6 +37,13 @@ export declare class CcDiscordBot {
37
37
  private channelNamespaceMap;
38
38
  private storeSnowflake;
39
39
  reverseSnowflakeLookup(n: number): string | undefined;
40
+ /** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
41
+ private persistChannelMapping;
42
+ /**
43
+ * Load persisted channel→namespace mappings from Redis and repopulate
44
+ * channelNamespaceMap + routedChannelIds. Call once on startup after the notifier is ready.
45
+ */
46
+ loadChannelMappings(): Promise<void>;
40
47
  /** Session key: "channelId" or "channelId:threadId" for threads */
41
48
  private sessionKey;
42
49
  /** Get the channel/thread for sending messages */
@@ -71,7 +78,8 @@ export declare class CcDiscordBot {
71
78
  * and start the meta-agent for `repoUrl`. Fire-and-forget after sending the confirmation message.
72
79
  */
73
80
  private createChannelForRepo;
74
- /** Write a message to the Redis chat log. Fire-and-forget. */
81
+ /** Write a message to the Redis chat log. Fire-and-forget.
82
+ * Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
75
83
  private writeChatMessage;
76
84
  /** Returns the last channelId that sent a message. */
77
85
  getLastActiveChannelId(): string | undefined;
package/dist/bot.js CHANGED
@@ -14,6 +14,10 @@ import { getCurrentToken } from "./tokens.js";
14
14
  import { writeChatLog } from "./notifier.js";
15
15
  import { CronManager } from "./cron.js";
16
16
  import { parseChannelCreateIntent, ensureMetaAgent, routeToMetaAgent } from "./router.js";
17
+ /** Redis key for persisting a Discord channelId → namespace mapping across restarts. */
18
+ function discordChannelKey(channelId) {
19
+ return `cca:discord:channel:${channelId}`;
20
+ }
17
21
  /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
18
22
  function snowflakeToInt(id) {
19
23
  // Discord snowflakes are up to 2^63, beyond Number.MAX_SAFE_INTEGER.
@@ -165,6 +169,49 @@ export class CcDiscordBot {
165
169
  reverseSnowflakeLookup(n) {
166
170
  return this.snowflakeMap.get(n);
167
171
  }
172
+ /** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
173
+ persistChannelMapping(channelId, namespace, repoUrl) {
174
+ if (!this.redis)
175
+ return;
176
+ const key = discordChannelKey(channelId);
177
+ const value = JSON.stringify({ namespace, repoUrl });
178
+ this.redis.set(key, value).catch((err) => {
179
+ console.warn(`[bot] persistChannelMapping failed for ${channelId}:`, err.message);
180
+ });
181
+ }
182
+ /**
183
+ * Load persisted channel→namespace mappings from Redis and repopulate
184
+ * channelNamespaceMap + routedChannelIds. Call once on startup after the notifier is ready.
185
+ */
186
+ async loadChannelMappings() {
187
+ if (!this.redis)
188
+ return;
189
+ let keys;
190
+ try {
191
+ keys = await this.redis.keys("cca:discord:channel:*");
192
+ }
193
+ catch (err) {
194
+ console.warn("[bot] loadChannelMappings keys scan failed:", err.message);
195
+ return;
196
+ }
197
+ for (const key of keys) {
198
+ try {
199
+ const raw = await this.redis.get(key);
200
+ if (!raw)
201
+ continue;
202
+ const { namespace, repoUrl } = JSON.parse(raw);
203
+ const channelId = key.slice("cca:discord:channel:".length);
204
+ if (!this.channelNamespaceMap.has(channelId)) {
205
+ this.channelNamespaceMap.set(channelId, { namespace, repoUrl });
206
+ this.opts.registerRoutedChannelId?.(namespace, channelId);
207
+ console.log(`[bot] restored channel mapping: ${channelId} → ${namespace}`);
208
+ }
209
+ }
210
+ catch (err) {
211
+ console.warn(`[bot] loadChannelMappings: failed to parse ${key}:`, err.message);
212
+ }
213
+ }
214
+ }
168
215
  /** Session key: "channelId" or "channelId:threadId" for threads */
169
216
  sessionKey(channelId, threadId) {
170
217
  return threadId ? `${channelId}:${threadId}` : channelId;
@@ -284,7 +331,7 @@ export class CcDiscordBot {
284
331
  // Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
285
332
  const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
286
333
  if (mappedNs && this.redis) {
287
- this.writeChatMessage("user", "discord", text, effectiveChannelId);
334
+ this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
288
335
  this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
289
336
  const username = msg.member?.displayName ?? msg.author.username;
290
337
  try {
@@ -642,6 +689,7 @@ export class CcDiscordBot {
642
689
  const newChannel = await guild.channels.create({ name: namespace, type: ChannelType.GuildText, parent: resolveCategoryId(guild) });
643
690
  this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
644
691
  this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
692
+ this.persistChannelMapping(newChannel.id, namespace, repoUrl);
645
693
  await interaction.editReply(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`);
646
694
  // Start meta-agent in the background
647
695
  if (this.redis) {
@@ -785,6 +833,7 @@ export class CcDiscordBot {
785
833
  }
786
834
  this.channelNamespaceMap.set(newChannel.id, { namespace, repoUrl });
787
835
  this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
836
+ this.persistChannelMapping(newChannel.id, namespace, repoUrl);
788
837
  await channel.send(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`).catch(() => { });
789
838
  // Start meta-agent in the background after acknowledging the user
790
839
  if (this.redis) {
@@ -795,8 +844,9 @@ export class CcDiscordBot {
795
844
  });
796
845
  }
797
846
  }
798
- /** Write a message to the Redis chat log. Fire-and-forget. */
799
- writeChatMessage(role, source, content, channelId) {
847
+ /** Write a message to the Redis chat log. Fire-and-forget.
848
+ * Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
849
+ writeChatMessage(role, source, content, channelId, ns) {
800
850
  if (!this.redis)
801
851
  return;
802
852
  const msg = {
@@ -807,7 +857,7 @@ export class CcDiscordBot {
807
857
  timestamp: new Date().toISOString(),
808
858
  chatId: snowflakeToInt(channelId),
809
859
  };
810
- writeChatLog(this.redis, this.namespace, msg);
860
+ writeChatLog(this.redis, ns ?? this.namespace, msg);
811
861
  }
812
862
  /** Returns the last channelId that sent a message. */
813
863
  getLastActiveChannelId() {
package/dist/index.js CHANGED
@@ -95,6 +95,10 @@ const bot = new CcDiscordBot({
95
95
  });
96
96
  const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n));
97
97
  console.log(`[notifier] started for namespace=${namespace} notifyChannelId=${notifyChannelId ?? "dynamic"}`);
98
+ // Restore persisted channel→namespace mappings so routing survives restarts
99
+ bot.loadChannelMappings().catch((err) => {
100
+ console.warn("[cc-discord] loadChannelMappings failed:", err.message);
101
+ });
98
102
  // Wire closures now that bot is constructed
99
103
  getLastActiveChannelIdFn = () => bot.getLastActiveChannelId();
100
104
  handleUserMessageFn = (channelId, text) => { void bot.handleUserMessage(channelId, text); };
@@ -26,21 +26,30 @@ export interface ParsedNotification {
26
26
  }
27
27
  /**
28
28
  * Parse a notification payload.
29
- * Returns the display text plus an optional chatId for per-channel routing.
29
+ * Returns the display text plus an optional chatId for per-channel routing,
30
+ * or null when the routing array excludes "discord".
30
31
  * Appends a [driver] or [driver:model] badge when present.
31
32
  * Appends " cost: $X.XXX" if a numeric cost field is present.
32
33
  */
33
- export declare function parseNotification(raw: string): ParsedNotification;
34
+ export declare function parseNotification(raw: string): ParsedNotification | null;
34
35
  /**
35
36
  * Write a message to the chat log in Redis.
36
37
  * Fire-and-forget — errors are logged but not thrown.
37
38
  */
38
39
  export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
40
+ /**
41
+ * Resolve the target Discord channelId for a notification.
42
+ * When chatId is set and a reverse-lookup function is available, prefer the originating channel.
43
+ * Falls back to notifyChannelId, then getActiveChannelId.
44
+ */
45
+ export declare function resolveNotifyChannel(chatId: number | undefined, notifyChannelId: string | null, getActiveChannelId?: () => string | undefined, reverseSnowflakeLookup?: (n: number) => string | undefined): string | undefined;
39
46
  export interface NotifierHandle {
40
47
  /**
41
48
  * Register the originating Discord channel ID for a routed namespace.
42
49
  * When the meta-agent for `namespace` publishes a response, it will be
43
50
  * forwarded to `channelId`.
51
+ * Also subscribes to notifyChannel(namespace) and chatIncomingChannel(namespace)
52
+ * so notifications and UI messages for that namespace are received.
44
53
  */
45
54
  registerRoutedChannelId: (namespace: string, channelId: string) => void;
46
55
  }
package/dist/notifier.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * cca:chat:log:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
11
11
  * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
12
12
  */
13
- import { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
13
+ import { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, notifyListKey, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
14
14
  import { splitLongMessage, stripAnsi } from "./formatter.js";
15
15
  function log(level, ...args) {
16
16
  const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
@@ -32,7 +32,8 @@ function shortenModelName(model, driver) {
32
32
  }
33
33
  /**
34
34
  * Parse a notification payload.
35
- * Returns the display text plus an optional chatId for per-channel routing.
35
+ * Returns the display text plus an optional chatId for per-channel routing,
36
+ * or null when the routing array excludes "discord".
36
37
  * Appends a [driver] or [driver:model] badge when present.
37
38
  * Appends " cost: $X.XXX" if a numeric cost field is present.
38
39
  */
@@ -44,6 +45,10 @@ export function parseNotification(raw) {
44
45
  let chatId;
45
46
  try {
46
47
  const parsed = JSON.parse(raw);
48
+ // routing: absent/empty → all transports; non-empty → only listed transports
49
+ if (parsed.routing && parsed.routing.length > 0 && !parsed.routing.includes("discord")) {
50
+ return null;
51
+ }
47
52
  if (parsed.text)
48
53
  text = parsed.text;
49
54
  driver = parsed.driver;
@@ -86,7 +91,7 @@ export function writeChatLog(redis, namespace, msg) {
86
91
  * When chatId is set and a reverse-lookup function is available, prefer the originating channel.
87
92
  * Falls back to notifyChannelId, then getActiveChannelId.
88
93
  */
89
- function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
94
+ export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) {
90
95
  if (chatId != null && reverseSnowflakeLookup) {
91
96
  const resolved = reverseSnowflakeLookup(chatId);
92
97
  if (resolved)
@@ -107,8 +112,10 @@ function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId, rever
107
112
  * @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
108
113
  */
109
114
  export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup) {
110
- // Per-namespace channelId registry
115
+ // Per-namespace channelId registry — maps routed namespace → Discord channelId
111
116
  const routedChannelIds = new Map();
117
+ // Track which namespaces we've already subscribed to (to avoid duplicate subscribe calls)
118
+ const subscribedNamespaces = new Set();
112
119
  const sub = redis.duplicate({
113
120
  retryStrategy: (times) => {
114
121
  const delay = Math.min(1000 * Math.pow(2, times - 1), 30_000);
@@ -122,25 +129,39 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
122
129
  sub.on("close", () => {
123
130
  log("info", "subscriber disconnected, will reconnect with backoff");
124
131
  });
125
- // notifyChannel(namespace) forward job completion notifications to Discord
126
- sub.subscribe(notifyChannel(namespace), (err) => {
127
- if (err) {
128
- log("error", `subscribe ${notifyChannel(namespace)} failed:`, err.message);
129
- }
130
- else {
131
- log("info", `subscribed to ${notifyChannel(namespace)}`);
132
- }
133
- });
134
- // chatIncomingChannel(namespace) — messages from UI
135
- sub.subscribe(chatIncomingChannel(namespace), (err) => {
136
- if (err) {
137
- log("error", `subscribe ${chatIncomingChannel(namespace)} failed:`, err.message);
138
- }
139
- else {
140
- log("info", `subscribed to ${chatIncomingChannel(namespace)}`);
141
- }
142
- });
143
- // chatOutgoingChannel("*") meta-agent stdout lines
132
+ // Reverse map: Redis channel string → namespace (for O(1) lookup in message handler)
133
+ const channelToNamespace = new Map();
134
+ function subscribeNamespace(ns) {
135
+ if (subscribedNamespaces.has(ns))
136
+ return;
137
+ subscribedNamespaces.add(ns);
138
+ const notifyCh = notifyChannel(ns);
139
+ const incomingCh = chatIncomingChannel(ns);
140
+ channelToNamespace.set(notifyCh, ns);
141
+ channelToNamespace.set(incomingCh, ns);
142
+ sub.subscribe(notifyCh, (err) => {
143
+ if (err) {
144
+ log("error", `subscribe ${notifyCh} failed:`, err.message);
145
+ }
146
+ else {
147
+ log("info", `subscribed to ${notifyCh}`);
148
+ }
149
+ });
150
+ sub.subscribe(incomingCh, (err) => {
151
+ if (err) {
152
+ log("error", `subscribe ${incomingCh} failed:`, err.message);
153
+ }
154
+ else {
155
+ log("info", `subscribed to ${incomingCh}`);
156
+ }
157
+ });
158
+ }
159
+ function resolveSubscribedNamespace(channel) {
160
+ return channelToNamespace.get(channel);
161
+ }
162
+ // Subscribe to the primary namespace immediately
163
+ subscribeNamespace(namespace);
164
+ // chatOutgoingChannel("*") — meta-agent stdout lines for ALL namespaces
144
165
  sub.psubscribe(chatOutgoingChannel("*"), (err) => {
145
166
  if (err) {
146
167
  log("error", `psubscribe ${chatOutgoingChannel("*")} failed:`, err.message);
@@ -181,7 +202,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
181
202
  const content = parsed.content;
182
203
  if (!content)
183
204
  return;
184
- const targetChannelId = routedChannelIds.get(ns) ?? notifyChannelId ?? getActiveChannelId?.();
205
+ // For the primary namespace, fall back to notifyChannelId / getActiveChannelId.
206
+ // For any other namespace, ONLY use the registered channelId — never fall back to
207
+ // the primary channel, as that would cause cross-namespace leakage.
208
+ const targetChannelId = ns === namespace
209
+ ? (routedChannelIds.get(ns) ?? notifyChannelId ?? getActiveChannelId?.())
210
+ : routedChannelIds.get(ns);
185
211
  if (targetChannelId == null) {
186
212
  log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
187
213
  return;
@@ -196,24 +222,21 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
196
222
  clearTimeout(buf.timer);
197
223
  buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), META_AGENT_FLUSH_DELAY_MS);
198
224
  });
199
- // Poll the notifyChannel(namespace) LIST every 5 seconds
200
- const notifyListKey = notifyChannel(namespace);
225
+ // Poll notifyListKey(ns) LIST every 5 seconds — covers primary + all routed namespaces.
201
226
  const MAX_PER_CYCLE = 20;
202
- const pollNotifyList = async () => {
203
- const targetId = notifyChannelId ?? getActiveChannelId?.();
204
- if (targetId == null)
205
- return;
227
+ const pollOneNamespace = async (ns, targetChannelId) => {
228
+ const listKey = notifyListKey(ns);
206
229
  const items = [];
207
230
  try {
208
231
  for (let i = 0; i < MAX_PER_CYCLE; i++) {
209
- const item = await redis.rpop(notifyListKey);
232
+ const item = await redis.rpop(listKey);
210
233
  if (item === null)
211
234
  break;
212
235
  items.push(item);
213
236
  }
214
237
  }
215
238
  catch (err) {
216
- log("warn", "notify list rpop failed:", err.message);
239
+ log("warn", `notify list rpop failed (ns=${ns}):`, err.message);
217
240
  return;
218
241
  }
219
242
  if (items.length === 0)
@@ -221,47 +244,82 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
221
244
  let remaining = 0;
222
245
  if (items.length === MAX_PER_CYCLE) {
223
246
  try {
224
- remaining = await redis.llen(notifyListKey);
247
+ remaining = await redis.llen(listKey);
225
248
  }
226
249
  catch (err) {
227
- log("warn", "notify list llen failed:", err.message);
250
+ log("warn", `notify list llen failed (ns=${ns}):`, err.message);
228
251
  }
229
252
  }
230
253
  for (const raw of items) {
231
254
  const notification = parseNotification(raw);
232
- const destChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) ?? targetId;
255
+ if (notification === null)
256
+ continue; // routing excludes discord
257
+ // Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup.
258
+ // Routed namespaces: always deliver to the registered Discord channelId — no leakage.
259
+ const destChannelId = ns === namespace
260
+ ? (resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup) ?? targetChannelId)
261
+ : targetChannelId;
233
262
  bot.sendToChannelById(destChannelId, notification.text).catch((err) => {
234
- log("warn", "notify list send failed:", err.message);
263
+ log("warn", `notify list send failed (ns=${ns}):`, err.message);
235
264
  });
236
265
  if (forwardNotification) {
237
266
  forwardNotification(destChannelId, notification.text);
238
267
  }
239
268
  }
240
269
  if (remaining > 0) {
241
- bot.sendToChannelById(targetId, `...and ${remaining} more notifications`).catch((err) => {
242
- log("warn", "notify list summary send failed:", err.message);
270
+ bot.sendToChannelById(targetChannelId, `...and ${remaining} more notifications`).catch((err) => {
271
+ log("warn", `notify list summary send failed (ns=${ns}):`, err.message);
243
272
  });
244
273
  }
245
274
  };
275
+ const pollNotifyList = async () => {
276
+ // Primary namespace
277
+ const primaryTargetId = notifyChannelId ?? getActiveChannelId?.();
278
+ if (primaryTargetId != null) {
279
+ await pollOneNamespace(namespace, primaryTargetId);
280
+ }
281
+ // All registered routed namespaces
282
+ for (const [ns, channelId] of routedChannelIds) {
283
+ if (ns !== namespace) {
284
+ await pollOneNamespace(ns, channelId);
285
+ }
286
+ }
287
+ };
246
288
  setInterval(() => {
247
289
  void pollNotifyList();
248
290
  }, 5_000);
249
291
  sub.on("message", (channel, message) => {
250
- const notifyCh = notifyChannel(namespace);
251
- const incomingCh = chatIncomingChannel(namespace);
292
+ // Determine which namespace this channel belongs to
293
+ const ns = resolveSubscribedNamespace(channel);
294
+ if (!ns)
295
+ return;
296
+ const isPrimary = ns === namespace;
297
+ const notifyCh = notifyChannel(ns);
298
+ const incomingCh = chatIncomingChannel(ns);
252
299
  if (channel === notifyCh) {
253
300
  const notification = parseNotification(message);
254
- const targetId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup);
301
+ if (notification === null)
302
+ return; // routing excludes discord
303
+ let targetId;
304
+ if (isPrimary) {
305
+ targetId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup);
306
+ }
307
+ else {
308
+ // For routed namespaces, only use the registered channelId — no fallback to primary
309
+ targetId = notification.chatId != null && reverseSnowflakeLookup
310
+ ? (reverseSnowflakeLookup(notification.chatId) ?? routedChannelIds.get(ns))
311
+ : routedChannelIds.get(ns);
312
+ }
255
313
  if (targetId != null) {
256
314
  bot.sendToChannelById(targetId, notification.text).catch((err) => {
257
- log("warn", "notify send failed:", err.message);
315
+ log("warn", `notify send failed (ns=${ns}):`, err.message);
258
316
  });
259
317
  if (forwardNotification) {
260
318
  forwardNotification(targetId, notification.text);
261
319
  }
262
320
  }
263
321
  else {
264
- log("warn", "notify: no channelId available, dropping notification");
322
+ log("warn", `notify: no channelId available for ns=${ns}, dropping notification`);
265
323
  }
266
324
  return;
267
325
  }
@@ -278,11 +336,13 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
278
336
  catch {
279
337
  // raw string message — use as-is
280
338
  }
281
- const targetChannelId = notifyChannelId ?? getActiveChannelId?.();
339
+ const targetChannelId = isPrimary
340
+ ? (notifyChannelId ?? getActiveChannelId?.())
341
+ : routedChannelIds.get(ns);
282
342
  if (targetChannelId !== undefined) {
283
343
  // Echo to Discord so the user sees UI messages
284
344
  bot.sendToChannelById(targetChannelId, `[from UI]: ${content}`).catch((err) => {
285
- log("warn", "sendToChannelById (UI echo) failed:", err.message);
345
+ log("warn", `sendToChannelById (UI echo) failed (ns=${ns}):`, err.message);
286
346
  });
287
347
  // Log the incoming message
288
348
  const inMsg = {
@@ -293,12 +353,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
293
353
  timestamp: originalTimestamp ?? new Date().toISOString(),
294
354
  chatId: 0, // no numeric chatId for Discord — stored by channelId string
295
355
  };
296
- writeChatLog(redis, namespace, inMsg);
356
+ writeChatLog(redis, ns, inMsg);
297
357
  // Check if a meta-agent is running; if so, route there instead
298
358
  void (async () => {
299
359
  let routedToMetaAgent = false;
300
360
  try {
301
- const statusRaw = await redis.get(metaAgentStatusKey(namespace));
361
+ const statusRaw = await redis.get(metaAgentStatusKey(ns));
302
362
  if (statusRaw) {
303
363
  const status = JSON.parse(statusRaw);
304
364
  if (status.status === "running") {
@@ -307,14 +367,14 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
307
367
  content,
308
368
  timestamp: new Date().toISOString(),
309
369
  });
310
- await redis.rpush(metaInputKey(namespace), entry);
311
- log("info", `cca:chat:incoming: routed to meta-agent for namespace ${namespace}`);
370
+ await redis.rpush(metaInputKey(ns), entry);
371
+ log("info", `cca:chat:incoming: routed to meta-agent for namespace ${ns}`);
312
372
  routedToMetaAgent = true;
313
373
  }
314
374
  }
315
375
  }
316
376
  catch (err) {
317
- log("warn", "meta-agent status check failed:", err.message);
377
+ log("warn", `meta-agent status check failed (ns=${ns}):`, err.message);
318
378
  }
319
379
  if (!routedToMetaAgent && handleUserMessage) {
320
380
  handleUserMessage(targetChannelId, content);
@@ -322,13 +382,16 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
322
382
  })();
323
383
  }
324
384
  else {
325
- log("warn", "cca:chat:incoming: no active channelId to route message to");
385
+ log("warn", `cca:chat:incoming: no active channelId for ns=${ns}, dropping message`);
326
386
  }
327
387
  }
328
388
  });
329
389
  return {
330
390
  registerRoutedChannelId: (ns, channelId) => {
331
391
  routedChannelIds.set(ns, channelId);
392
+ // Subscribe to this namespace's Redis channels so we receive its notifications
393
+ // and incoming UI messages. No-op if already subscribed.
394
+ subscribeNamespace(ns);
332
395
  },
333
396
  };
334
397
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "dist/"
19
19
  ],
20
20
  "dependencies": {
21
- "@gonzih/cc-wire": "^0.1.4",
21
+ "@gonzih/cc-wire": "^0.1.6",
22
22
  "discord.js": "^14.0.0",
23
23
  "ioredis": "^5.0.0"
24
24
  },