@gonzih/cc-discord 0.2.21 → 0.2.23

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,7 +3,6 @@
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";
7
6
  /** Returns true if the attachment name/contentType indicates an audio file. */
8
7
  export declare function isAudioAttachment(name: string, contentType: string): boolean;
9
8
  /** Build the prompt text for a file/document attachment, optionally with caption. */
@@ -40,7 +39,6 @@ export declare class CcDiscordBot {
40
39
  private metaAgentManager;
41
40
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
42
41
  private mcpSession?;
43
- private loopManager?;
44
42
  constructor(opts: DiscordBotOptions);
45
43
  /** Reverse-lookup: find the channelId string for a cron-stored integer */
46
44
  private snowflakeMap;
@@ -69,6 +67,12 @@ export declare class CcDiscordBot {
69
67
  private sendToChannel;
70
68
  /** Send to a channel by ID — used by notifier callbacks. */
71
69
  sendToChannelById(channelId: string, text: string): Promise<void>;
70
+ /**
71
+ * After sending a Discord message on behalf of a cron, look up the pending cron ID
72
+ * for the channel's namespace and store cca:discord:cron-message:{messageId} = cronId.
73
+ * This lets the ❌/🛑 reaction handler disable the cron that fired this message.
74
+ */
75
+ private storeCronMessageMapping;
72
76
  /**
73
77
  * Send text to a channel by ID, scanning for absolute file paths and attaching them.
74
78
  * Used exclusively for Claude coordinator output (meta-agent flush).
@@ -115,18 +119,13 @@ export declare class CcDiscordBot {
115
119
  /** Reverse lookup: find the Discord channelId registered for a given namespace. */
116
120
  getChannelIdForNamespace(ns: string): string | undefined;
117
121
  /** Return the thread ID for an active loop on `channelId`, or undefined. */
118
- getLoopThreadId(channelId: string): string | undefined;
119
- /**
120
- * Post a structured eval-report embed to the loop thread for `channelId`.
121
- * Also records gate failures in the LoopManager if the gate did not pass.
122
- */
123
- postEvalEmbed(channelId: string, report: EvalReport): Promise<void>;
122
+ getLoopThreadId(_channelId: string): string | undefined;
123
+ /** No-op stub kept for notifier compatibility — loop threads removed. */
124
+ postEvalEmbed(_channelId: string, _report: unknown): Promise<void>;
124
125
  /**
125
- * Create a Discord thread on `msg`, register loop state, and add reaction gates.
126
- * Requires MANAGE_THREADS permission. Falls back silently on failure.
126
+ * Handle ❌/🛑 reactions on cron-fired messages to disable the cron.
127
+ * Also no-ops the old loop gate reactions (🔄/✅) gracefully.
127
128
  */
128
- private createLoopThread;
129
- /** Handle 🔄/✅/❌ reactions on loop gate messages. */
130
129
  private handleReactionAdd;
131
130
  /**
132
131
  * Feed a text message into the active Claude session for the given channel.
package/dist/bot.js CHANGED
@@ -14,9 +14,8 @@ import { transcribeVoice, isVoiceAvailable } from "./voice.js";
14
14
  import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
15
15
  import { getCurrentToken } from "./tokens.js";
16
16
  import { writeChatLog } from "./notifier.js";
17
- import { LoopManager, isGoalMessage } from "./loop-manager.js";
18
17
  import { CronManager } from "./cron.js";
19
- import { CronEngine } from "./cron-engine.js";
18
+ import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
20
19
  import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
21
20
  import { createMetaAgentManager } from "./meta-agent-manager.js";
22
21
  /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
@@ -126,14 +125,12 @@ export class CcDiscordBot {
126
125
  metaAgentManager;
127
126
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
128
127
  mcpSession;
129
- loopManager;
130
128
  constructor(opts) {
131
129
  this.opts = opts;
132
130
  this.redis = opts.redis;
133
131
  this.namespace = opts.namespace ?? "default";
134
132
  if (opts.redis) {
135
133
  this.wire = createCcWire(opts.redis);
136
- this.loopManager = new LoopManager(opts.redis);
137
134
  this.cronEngine = new CronEngine(opts.redis);
138
135
  }
139
136
  this.metaAgentManager = createMetaAgentManager();
@@ -305,6 +302,26 @@ export class CcDiscordBot {
305
302
  }
306
303
  await this.sendToChannel(channel, text);
307
304
  }
305
+ /**
306
+ * After sending a Discord message on behalf of a cron, look up the pending cron ID
307
+ * for the channel's namespace and store cca:discord:cron-message:{messageId} = cronId.
308
+ * This lets the ❌/🛑 reaction handler disable the cron that fired this message.
309
+ */
310
+ async storeCronMessageMapping(messageId, channelId) {
311
+ if (!this.redis)
312
+ return;
313
+ const ns = this.resolveNamespaceForChannel(channelId);
314
+ try {
315
+ const cronId = await this.redis.get(cronPendingKey(ns));
316
+ if (!cronId)
317
+ return;
318
+ await this.redis.set(`cca:discord:cron-message:${messageId}`, cronId, "EX", CRON_MESSAGE_TTL);
319
+ console.log(`[bot] linked message ${messageId} → cron ${cronId} (ns=${ns})`);
320
+ }
321
+ catch (err) {
322
+ console.warn(`[bot] storeCronMessageMapping failed (ns=${ns}):`, err.message);
323
+ }
324
+ }
308
325
  /**
309
326
  * Send text to a channel by ID, scanning for absolute file paths and attaching them.
310
327
  * Used exclusively for Claude coordinator output (meta-agent flush).
@@ -346,15 +363,19 @@ export class CcDiscordBot {
346
363
  // Send first chunk with file attachments, remaining chunks as plain text
347
364
  const files = validPaths.map((p) => ({ attachment: p, name: basename(p) }));
348
365
  try {
349
- await channel.send({ content: chunks[0] || undefined, files });
366
+ const sent = await channel.send({ content: chunks[0] || undefined, files });
367
+ void this.storeCronMessageMapping(sent.id, channelId);
350
368
  }
351
369
  catch (err) {
352
370
  console.warn(`[bot] sendWithFileDetection attach failed:`, err.message);
353
371
  // Fall back to plain text for the first chunk
354
372
  if (chunks[0]?.trim()) {
355
- await channel.send(chunks[0]).catch((e) => {
373
+ const sent = await channel.send(chunks[0]).catch((e) => {
356
374
  console.error("[bot] sendWithFileDetection fallback failed:", e.message);
375
+ return null;
357
376
  });
377
+ if (sent)
378
+ void this.storeCronMessageMapping(sent.id, channelId);
358
379
  }
359
380
  }
360
381
  for (const chunk of chunks.slice(1)) {
@@ -440,43 +461,13 @@ export class CcDiscordBot {
440
461
  return;
441
462
  }
442
463
  }
443
- // Handle messages sent inside a loop thread — route to the running meta-agent session
444
- if (msg.channel.isThread() && this.redis && this.loopManager) {
445
- const parentId = msg.channel.parentId;
446
- if (parentId) {
447
- const loopState = this.loopManager.getState(parentId);
448
- if (loopState && loopState.threadId === effectiveChannelId) {
449
- const username = msg.member?.displayName ?? msg.author.username;
450
- this.startMetaAgentTyping(effectiveChannelId, msg.channel);
451
- try {
452
- await routeToMetaAgent(loopState.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
453
- }
454
- catch (err) {
455
- await msg.channel.send(`Failed to route: ${err.message}`).catch(() => { });
456
- }
457
- return;
458
- }
459
- }
460
- }
461
464
  // Channel registered via createChannelForRepo or /channel — route directly to its meta-agent
462
465
  const mappedNs = this.channelNamespaceMap.get(effectiveChannelId);
463
466
  if (mappedNs && this.redis) {
464
- // If a loop is already running for this channel, point the user to the thread
465
- if (this.loopManager?.isActive(effectiveChannelId)) {
466
- const loopState = this.loopManager.getState(effectiveChannelId);
467
- await msg.channel.send(`Loop in progress → <#${loopState.threadId}>\nSend messages in the thread to interact with the running loop.`).catch(() => { });
468
- return;
469
- }
470
467
  this.writeChatMessage("user", "discord", text, effectiveChannelId, mappedNs.namespace);
471
468
  this.opts.registerRoutedChannelId?.(mappedNs.namespace, effectiveChannelId);
472
469
  this.persistChannelMapping(effectiveChannelId, mappedNs.namespace, mappedNs.repoUrl);
473
470
  this.startMetaAgentTyping(effectiveChannelId, msg.channel);
474
- // Detect goal messages → create a thread for loop tracking
475
- if (this.loopManager && msg.guild && !msg.channel.isThread()) {
476
- if (isGoalMessage(text)) {
477
- await this.createLoopThread(msg, effectiveChannelId, mappedNs.namespace, text);
478
- }
479
- }
480
471
  const username = msg.member?.displayName ?? msg.author.username;
481
472
  try {
482
473
  await routeToMetaAgent(mappedNs.namespace, stampPrompt(text, username, msg.createdAt), this.redis);
@@ -1243,74 +1234,26 @@ export class CcDiscordBot {
1243
1234
  return undefined;
1244
1235
  }
1245
1236
  /** Return the thread ID for an active loop on `channelId`, or undefined. */
1246
- getLoopThreadId(channelId) {
1247
- return this.loopManager?.getThreadId(channelId);
1237
+ getLoopThreadId(_channelId) {
1238
+ // Loop-as-threads removed — cron engine is the loop mechanism.
1239
+ return undefined;
1248
1240
  }
1249
- /**
1250
- * Post a structured eval-report embed to the loop thread for `channelId`.
1251
- * Also records gate failures in the LoopManager if the gate did not pass.
1252
- */
1253
- async postEvalEmbed(channelId, report) {
1254
- const threadId = this.loopManager?.getThreadId(channelId);
1255
- if (!threadId)
1256
- return;
1257
- const channel = await this.getChannel(threadId);
1258
- if (!channel)
1259
- return;
1260
- const color = report.passed ? 0x57F287 : 0xED4245; // green / red
1261
- const embed = new EmbedBuilder()
1262
- .setTitle(`${report.passed ? "✅" : "❌"} Gate: ${report.gate}`)
1263
- .setColor(color)
1264
- .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 })
1265
- .setTimestamp();
1266
- await channel.send({ embeds: [embed] }).catch((err) => {
1267
- console.warn(`[bot] postEvalEmbed send failed:`, err.message);
1268
- });
1269
- if (!report.passed && this.loopManager) {
1270
- await this.loopManager.addGateFailure(channelId, {
1271
- gate: report.gate,
1272
- feedback: report.feedback,
1273
- iteration: report.iteration,
1274
- confidence: report.confidence,
1275
- timestamp: new Date().toISOString(),
1276
- });
1277
- }
1241
+ /** No-op stub kept for notifier compatibility — loop threads removed. */
1242
+ async postEvalEmbed(_channelId, _report) {
1243
+ return;
1278
1244
  }
1279
1245
  /**
1280
- * Create a Discord thread on `msg`, register loop state, and add reaction gates.
1281
- * Requires MANAGE_THREADS permission. Falls back silently on failure.
1246
+ * Handle ❌/🛑 reactions on cron-fired messages to disable the cron.
1247
+ * Also no-ops the old loop gate reactions (🔄/✅) gracefully.
1282
1248
  */
1283
- async createLoopThread(msg, channelId, namespace, goal) {
1284
- try {
1285
- const shortGoal = goal.slice(0, 48).replace(/\s+/g, " ");
1286
- const threadName = `Goal: ${shortGoal}${goal.length > 48 ? "…" : ""}`;
1287
- const thread = await msg.channel.threads.create({
1288
- name: threadName,
1289
- autoArchiveDuration: 10080, // 1 week
1290
- startMessage: msg,
1291
- reason: "Loop observability thread",
1292
- });
1293
- const firstMsg = await thread.send(`🎯 **Loop tracking thread**\n> ${goal.slice(0, 200)}\n\nReact to control the loop:\n` +
1294
- `🔄 Retry current iteration ✅ Accept & exit ❌ Kill loop`);
1295
- await firstMsg.react("🔄");
1296
- await firstMsg.react("✅");
1297
- await firstMsg.react("❌");
1298
- await this.loopManager.startLoop(channelId, thread.id, firstMsg.id, namespace, goal);
1299
- console.log(`[bot] loop thread created: channelId=${channelId} threadId=${thread.id}`);
1300
- }
1301
- catch (err) {
1302
- console.warn(`[bot] createLoopThread failed:`, err.message);
1303
- }
1304
- }
1305
- /** Handle 🔄/✅/❌ reactions on loop gate messages. */
1306
1249
  async handleReactionAdd(reaction, user) {
1307
1250
  if (user.bot)
1308
1251
  return;
1309
1252
  if (!this.isAllowed(user.id))
1310
1253
  return;
1311
- if (!this.loopManager || !this.redis)
1254
+ if (!this.redis || !this.cronEngine)
1312
1255
  return;
1313
- // Fetch full reaction object if partial (bot doesn't have it cached)
1256
+ // Fetch full reaction object if partial
1314
1257
  let fullReaction = reaction;
1315
1258
  if (reaction.partial) {
1316
1259
  try {
@@ -1321,46 +1264,20 @@ export class CcDiscordBot {
1321
1264
  }
1322
1265
  }
1323
1266
  const emoji = fullReaction.emoji.name;
1324
- if (emoji !== "🔄" && emoji !== "" && emoji !== "❌")
1267
+ if (emoji !== "" && emoji !== "🛑")
1325
1268
  return;
1326
1269
  const messageId = fullReaction.message.id;
1327
- const channelId = this.loopManager.getChannelIdByReactionMessage(messageId);
1328
- if (!channelId)
1329
- return;
1330
- const loopState = this.loopManager.getState(channelId);
1331
- if (!loopState)
1270
+ // Look up which cron fired this message
1271
+ const cronId = await this.redis.get(`cca:discord:cron-message:${messageId}`).catch(() => null);
1272
+ if (!cronId)
1332
1273
  return;
1333
- const thread = await this.getChannel(loopState.threadId);
1334
- if (emoji === "🔄") {
1335
- if (thread) {
1336
- await thread.send(`🔄 Retry requested re-running iteration ${loopState.iteration + 1}`).catch(() => { });
1337
- }
1338
- try {
1339
- await routeToMetaAgent(loopState.namespace, `Please retry the previous goal: ${loopState.goal}`, this.redis);
1340
- }
1341
- catch (err) {
1342
- console.warn(`[bot] retry routeToMetaAgent failed:`, err.message);
1343
- }
1344
- }
1345
- else if (emoji === "✅") {
1346
- if (thread) {
1347
- await thread.send("✅ Loop accepted — marking complete").catch(() => { });
1348
- }
1349
- await this.sendToChannelById(channelId, `✅ Goal completed: ${loopState.goal.slice(0, 100)}`);
1350
- await this.loopManager.endLoop(channelId);
1351
- if (thread && thread.isThread()) {
1352
- await thread.setArchived(true).catch(() => { });
1353
- }
1274
+ // Disable the cron by setting enabled="false" in its hash
1275
+ try {
1276
+ await this.redis.hset(`cca:discord:cron:${cronId}`, "enabled", "0");
1277
+ console.log(`[bot] cron ${cronId} disabled via ${emoji} reaction on message ${messageId}`);
1354
1278
  }
1355
- else if (emoji === "❌") {
1356
- if (thread) {
1357
- await thread.send("❌ Loop killed by user").catch(() => { });
1358
- }
1359
- await this.sendToChannelById(channelId, `❌ Loop killed: ${loopState.goal.slice(0, 100)}`);
1360
- await this.loopManager.endLoop(channelId);
1361
- if (thread && thread.isThread()) {
1362
- await thread.setArchived(true).catch(() => { });
1363
- }
1279
+ catch (err) {
1280
+ console.warn(`[bot] failed to disable cron ${cronId}:`, err.message);
1364
1281
  }
1365
1282
  }
1366
1283
  /**
@@ -17,6 +17,18 @@
17
17
  * 5. Update last_fired_at
18
18
  */
19
19
  import type { Redis } from "ioredis";
20
+ /**
21
+ * Temporary key written by the cron engine when it fires a cron.
22
+ * Value is the cronId that fired. TTL 300s.
23
+ * The bot reads this after sending a Discord message to link the message to the cron.
24
+ */
25
+ export declare const cronPendingKey: (ns: string) => string;
26
+ /**
27
+ * Key that maps a sent Discord messageId → cronId.
28
+ * Used by the reaction handler to look up which cron to disable.
29
+ * TTL 86400s (24h).
30
+ */
31
+ export declare const CRON_MESSAGE_TTL = 86400;
20
32
  export interface CronRecord {
21
33
  id: string;
22
34
  namespace: string;
@@ -18,10 +18,21 @@
18
18
  */
19
19
  import { randomUUID } from "crypto";
20
20
  import { schedule, validate } from "node-cron";
21
+ import { cronListKey, cronHashKey, discordMetaInputKey as metaInputKey, } from "@gonzih/cc-wire";
21
22
  // ─── Redis key helpers ────────────────────────────────────────────────────────
22
- const CRON_LIST_KEY = "cca:discord:cron:list";
23
- const cronHashKey = (id) => `cca:discord:cron:${id}`;
24
- const metaInputKey = (ns) => `cca:discord:meta:${ns}:input`;
23
+ const CRON_LIST_KEY = cronListKey();
24
+ /**
25
+ * Temporary key written by the cron engine when it fires a cron.
26
+ * Value is the cronId that fired. TTL 300s.
27
+ * The bot reads this after sending a Discord message to link the message to the cron.
28
+ */
29
+ export const cronPendingKey = (ns) => `cca:discord:cron-pending:${ns}`;
30
+ /**
31
+ * Key that maps a sent Discord messageId → cronId.
32
+ * Used by the reaction handler to look up which cron to disable.
33
+ * TTL 86400s (24h).
34
+ */
35
+ export const CRON_MESSAGE_TTL = 86400;
25
36
  // ─── CronEngine ──────────────────────────────────────────────────────────────
26
37
  export class CronEngine {
27
38
  redis;
@@ -230,6 +241,9 @@ export class CronEngine {
230
241
  await this.redis.rpush(inputKey, entry);
231
242
  rec.last_fired_at = new Date().toISOString();
232
243
  console.log(`[cron-engine] fire: pushed message (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
244
+ // Record which cron fired for this namespace — the bot reads this after
245
+ // sending the Discord message to store the cca:discord:cron-message:{msgId} mapping.
246
+ await this.redis.set(cronPendingKey(ns), id, "EX", 300);
233
247
  }
234
248
  catch (err) {
235
249
  console.warn(`[cron-engine] fire: push failed (id=${id}):`, err.message);
package/dist/index.js CHANGED
@@ -22,7 +22,7 @@
22
22
  import { createRequire } from "node:module";
23
23
  import { randomUUID } from "crypto";
24
24
  import { Redis } from "ioredis";
25
- import { createCcWire } from "@gonzih/cc-wire";
25
+ import { createCcWire, DISCORD_INSTANCE_KEY } from "@gonzih/cc-wire";
26
26
  import { CcDiscordBot } from "./bot.js";
27
27
  import { startNotifier } from "./notifier.js";
28
28
  import { loadTokens } from "./tokens.js";
@@ -80,7 +80,6 @@ sharedRedis.on("error", (err) => {
80
80
  const wire = createCcWire(sharedRedis);
81
81
  // Singleton instance ID — used to detect stale processes after a restart
82
82
  const instanceId = randomUUID();
83
- const INSTANCE_KEY = "cca:discord:instance";
84
83
  const INSTANCE_TTL_MS = 30_000;
85
84
  const INSTANCE_REFRESH_MS = 10_000;
86
85
  sharedRedis.once("ready", () => {
@@ -90,13 +89,13 @@ sharedRedis.once("ready", () => {
90
89
  });
91
90
  console.log(`[cc-discord] version:reported ${version}`);
92
91
  // Write singleton instance ID with 30s TTL
93
- sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
92
+ sharedRedis.set(DISCORD_INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
94
93
  console.warn("[cc-discord] failed to write instance ID:", err.message);
95
94
  });
96
95
  console.log(`[cc-discord] instance:${instanceId}`);
97
96
  // Refresh TTL every 10s so launchd-respawned processes displace old ones
98
97
  setInterval(() => {
99
- sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
98
+ sharedRedis.set(DISCORD_INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
100
99
  console.warn("[cc-discord] instance refresh failed:", err.message);
101
100
  });
102
101
  }, INSTANCE_REFRESH_MS);
@@ -34,9 +34,10 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
34
34
  * Redis keys for meta-agent stdout streaming.
35
35
  * cca:meta:{ns}:stream — pub/sub channel (live streaming)
36
36
  * cca:meta:{ns}:log — list (history, capped at 2000)
37
+ * Re-exported from cc-wire for backwards-compat with callers in this package.
37
38
  */
38
- export declare function metaStreamChannel(ns: string): string;
39
- export declare function metaLogKey(ns: string): string;
39
+ export declare const metaStreamChannel: (ns: string) => string;
40
+ export declare const metaLogKey: (ns: string) => string;
40
41
  /**
41
42
  * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
42
43
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
@@ -11,7 +11,7 @@ import { spawn, execSync } from "child_process";
11
11
  import { existsSync, mkdirSync, writeFileSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { homedir } from "os";
14
- import { CC_DISCORD_WORKSPACE_ROOT, TIMING, discordMetaInputKey, } from "@gonzih/cc-wire";
14
+ import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
15
15
  const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
16
16
  /**
17
17
  * Returns the path to the workspace for the given namespace.
@@ -120,13 +120,10 @@ function resolveClaude() {
120
120
  * Redis keys for meta-agent stdout streaming.
121
121
  * cca:meta:{ns}:stream — pub/sub channel (live streaming)
122
122
  * cca:meta:{ns}:log — list (history, capped at 2000)
123
+ * Re-exported from cc-wire for backwards-compat with callers in this package.
123
124
  */
124
- export function metaStreamChannel(ns) {
125
- return `cca:meta:${ns}:stream`;
126
- }
127
- export function metaLogKey(ns) {
128
- return `cca:meta:${ns}:log`;
129
- }
125
+ export const metaStreamChannel = ccWireMetaStreamChannel;
126
+ export const metaLogKey = ccWireMetaLogKey;
130
127
  /**
131
128
  * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
132
129
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
@@ -149,52 +146,113 @@ export function spawnSession(ns, message, token, wire) {
149
146
  const proc = spawn(claudeBin, [
150
147
  "--continue",
151
148
  "-p", message,
152
- "--output-format", "text",
149
+ "--output-format", "stream-json",
153
150
  "--verbose",
154
151
  "--dangerously-skip-permissions",
155
152
  ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
156
153
  let lineBuffer = "";
157
- const publishLine = (line) => {
158
- const trimmed = line.trim();
159
- if (!trimmed)
160
- return;
161
- const msg = {
162
- id: crypto.randomUUID(),
163
- source: "claude",
164
- role: "assistant",
165
- content: trimmed,
166
- timestamp: new Date().toISOString(),
167
- chatId: 0,
168
- };
169
- wire.discord.publishOutgoing(ns, msg).catch((err) => {
170
- console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
171
- });
172
- };
154
+ /**
155
+ * Parse a JSONL line from claude's stream-json stdout into a structured event.
156
+ * For lines that fail JSON.parse, emit { type: "text", text: line }.
157
+ * Forward each event as a JSON string to Redis:
158
+ * PUBLISH cca:meta:{ns}:stream
159
+ * LPUSH cca:meta:{ns}:log (capped at 2000)
160
+ */
173
161
  const rawRedis = wire._redis;
174
- const streamToRedis = (chunk) => {
175
- const entry = JSON.stringify({ ts: Date.now(), ns, text: chunk });
176
- const streamCh = metaStreamChannel(ns);
177
- const logKey = metaLogKey(ns);
178
- // Publish for live consumers (fire-and-forget)
179
- rawRedis.publish(streamCh, entry).catch((err) => {
162
+ const streamCh = metaStreamChannel(ns);
163
+ const logKey = metaLogKey(ns);
164
+ const forwardEventToRedis = (eventJson) => {
165
+ rawRedis.publish(streamCh, eventJson).catch((err) => {
180
166
  console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
181
167
  });
182
- // Persist to log list, cap at 2000
183
- rawRedis.lpush(logKey, entry).then(() => {
168
+ rawRedis.lpush(logKey, eventJson).then(() => {
184
169
  rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
185
170
  }).catch((err) => {
186
171
  console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
187
172
  });
188
173
  };
174
+ const processLine = (line) => {
175
+ const trimmed = line.trim();
176
+ if (!trimmed)
177
+ return;
178
+ let structuredEvent;
179
+ try {
180
+ const parsed = JSON.parse(trimmed);
181
+ // Map claude stream-json event shapes to our canonical structured event format
182
+ const type = parsed.type;
183
+ if (type === "assistant") {
184
+ // Extract text from content blocks
185
+ const content = parsed.message;
186
+ const textBlock = content?.content?.find((b) => b.type === "text");
187
+ const text = textBlock?.text ?? "";
188
+ structuredEvent = { type: "assistant", text };
189
+ // Also publish to Discord outgoing channel
190
+ if (text) {
191
+ const msg = {
192
+ id: crypto.randomUUID(),
193
+ source: "claude",
194
+ role: "assistant",
195
+ content: text,
196
+ timestamp: new Date().toISOString(),
197
+ chatId: 0,
198
+ };
199
+ wire.discord.publishOutgoing(ns, msg).catch((err) => {
200
+ console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
201
+ });
202
+ }
203
+ }
204
+ else if (type === "tool_use") {
205
+ structuredEvent = {
206
+ type: "tool_use",
207
+ name: parsed.name ?? "",
208
+ input: parsed.input ?? {},
209
+ };
210
+ }
211
+ else if (type === "tool_result") {
212
+ structuredEvent = {
213
+ type: "tool_result",
214
+ content: parsed.content ?? "",
215
+ };
216
+ }
217
+ else if (type === "result") {
218
+ const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
219
+ structuredEvent = {
220
+ type: "result",
221
+ result: resultText,
222
+ is_error: parsed.is_error ?? false,
223
+ };
224
+ // Publish result text to Discord as final assistant message
225
+ if (resultText) {
226
+ const msg = {
227
+ id: crypto.randomUUID(),
228
+ source: "claude",
229
+ role: "assistant",
230
+ content: resultText,
231
+ timestamp: new Date().toISOString(),
232
+ chatId: 0,
233
+ };
234
+ wire.discord.publishOutgoing(ns, msg).catch((err) => {
235
+ console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
236
+ });
237
+ }
238
+ }
239
+ else {
240
+ // Forward other event types as-is
241
+ structuredEvent = parsed;
242
+ }
243
+ }
244
+ catch {
245
+ // Non-JSON line — wrap as text event
246
+ structuredEvent = { type: "text", text: trimmed };
247
+ }
248
+ forwardEventToRedis(JSON.stringify(structuredEvent));
249
+ };
189
250
  proc.stdout.on("data", (chunk) => {
190
- const chunkStr = chunk.toString();
191
- // Stream raw chunk to Redis (before line-splitting)
192
- streamToRedis(chunkStr);
193
- lineBuffer += chunkStr;
251
+ lineBuffer += chunk.toString();
194
252
  const lines = lineBuffer.split("\n");
195
253
  lineBuffer = lines.pop() ?? "";
196
254
  for (const line of lines) {
197
- publishLine(line);
255
+ processLine(line);
198
256
  }
199
257
  });
200
258
  proc.stderr.on("data", (chunk) => {
@@ -205,7 +263,7 @@ export function spawnSession(ns, message, token, wire) {
205
263
  proc.on("exit", (code) => {
206
264
  // Flush any remaining buffered content
207
265
  if (lineBuffer.trim())
208
- publishLine(lineBuffer);
266
+ processLine(lineBuffer);
209
267
  lineBuffer = "";
210
268
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
211
269
  resolve();
@@ -233,7 +291,6 @@ export function createMetaAgentManager() {
233
291
  startPolling(wire, getNamespaces, instanceId) {
234
292
  if (pollInterval)
235
293
  return; // already running
236
- const INSTANCE_KEY = "cca:discord:instance";
237
294
  pollInterval = setInterval(() => {
238
295
  const namespaces = getNamespaces();
239
296
  if (namespaces.length === 0)
@@ -248,7 +305,7 @@ export function createMetaAgentManager() {
248
305
  // Staleness check: if a newer instance has registered, this process is stale — exit.
249
306
  if (instanceId) {
250
307
  try {
251
- const current = await wire._redis.get(INSTANCE_KEY);
308
+ const current = await wire._redis.get(DISCORD_INSTANCE_KEY);
252
309
  if (current && current !== instanceId) {
253
310
  console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
254
311
  process.exit(0);
@@ -11,7 +11,15 @@
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
+ /** Eval report from a meta-agent notification. */
15
+ export interface EvalReport {
16
+ gate: string;
17
+ passed: boolean;
18
+ feedback: string;
19
+ iteration: number;
20
+ maxIterations: number;
21
+ confidence: number;
22
+ }
15
23
  import type { CcDiscordBot } from "./bot.js";
16
24
  export interface ChatMessage {
17
25
  id: string;
package/dist/notifier.js CHANGED
@@ -11,12 +11,30 @@
11
11
  * cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
12
12
  */
13
13
  import { createHash } from "crypto";
14
- import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
14
+ import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, dedupKey, } from "@gonzih/cc-wire";
15
15
  import { splitLongMessage, stripAnsi } from "./formatter.js";
16
- import { parseEvalReport } from "./loop-manager.js";
17
- /** Redis key for per-namespace notification dedup set */
18
- function dedupKey(ns) {
19
- return `cca:discord:sent:${ns}`;
16
+ /**
17
+ * Parse an `eval_report` object embedded in a raw notification JSON string.
18
+ * Returns null when the field is absent, malformed, or the input is not JSON.
19
+ */
20
+ function parseEvalReport(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ const r = parsed.eval_report;
24
+ if (!r || typeof r.gate !== "string" || typeof r.passed !== "boolean")
25
+ return null;
26
+ return {
27
+ gate: r.gate,
28
+ passed: r.passed,
29
+ feedback: typeof r.feedback === "string" ? r.feedback : "",
30
+ iteration: typeof r.iteration === "number" ? r.iteration : 0,
31
+ maxIterations: typeof r.max_iterations === "number" ? r.max_iterations : 0,
32
+ confidence: typeof r.confidence === "number" ? r.confidence : 0,
33
+ };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
20
38
  }
21
39
  /** Compute a short stable dedup fingerprint for a raw notification string */
22
40
  function notifFingerprint(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "dist/"
20
20
  ],
21
21
  "dependencies": {
22
- "@gonzih/cc-wire": "^0.3.0",
22
+ "@gonzih/cc-wire": "^0.4.0",
23
23
  "@types/node-cron": "^3.0.11",
24
24
  "discord.js": "^14.0.0",
25
25
  "ioredis": "^5.0.0",
@@ -1,66 +0,0 @@
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
- }
@@ -1,118 +0,0 @@
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
- }