@gonzih/cc-discord 0.2.40 → 0.2.41

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
@@ -2,6 +2,7 @@
2
2
  * Discord bot that routes messages to/from a Claude Code subprocess.
3
3
  * One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
4
4
  */
5
+ import { Message } from "discord.js";
5
6
  import { Redis } from "ioredis";
6
7
  /** Returns true if the attachment name/contentType indicates an audio file. */
7
8
  export declare function isAudioAttachment(name: string, contentType: string): boolean;
@@ -56,10 +57,30 @@ export declare class CcDiscordBot {
56
57
  loadChannelMappings(): Promise<void>;
57
58
  /** Typing intervals for meta-agent routed channels — keyed by channelId. */
58
59
  private metaAgentTypingTimers;
60
+ /** Live Discord messages per channel — edited in-place as Claude streams output. */
61
+ private liveMessages;
59
62
  /** Start (or reset) the typing indicator for a meta-agent–routed channel. */
60
63
  private startMetaAgentTyping;
61
64
  /** Stop the typing indicator for a meta-agent–routed channel. Called by the notifier on flush. */
62
65
  stopMetaAgentTyping(channelId: string): void;
66
+ /**
67
+ * Get or create a live Discord message for `channelId`.
68
+ * If a live message already exists (e.g. from a tool_start), returns it (for reuse).
69
+ * Otherwise sends `initial` to create the placeholder and stops the typing indicator.
70
+ */
71
+ startOrGetLiveMessage(channelId: string, initial: string): Promise<Message | null>;
72
+ /**
73
+ * Throttled edit of the live message for `channelId`.
74
+ * Safe to call on every token/chunk — edits are coalesced at 750ms intervals.
75
+ * The latest `text` always wins (older pending updates are replaced).
76
+ */
77
+ updateLiveMessage(channelId: string, text: string): void;
78
+ /**
79
+ * Final edit of the live message — applies `text` without cursor, clears tracker.
80
+ * If no live message exists, falls back to sendWithFileDetection (new message).
81
+ * Overflow past 2000 chars spawns continuation messages via sendToChannelById.
82
+ */
83
+ finalizeLiveMessage(channelId: string, text: string): Promise<void>;
63
84
  /** Session key: "channelId" or "channelId:threadId" for threads */
64
85
  private sessionKey;
65
86
  /** Get the channel/thread for sending messages */
package/dist/bot.js CHANGED
@@ -231,6 +231,8 @@ export class CcDiscordBot {
231
231
  }
232
232
  /** Typing intervals for meta-agent routed channels — keyed by channelId. */
233
233
  metaAgentTypingTimers = new Map();
234
+ /** Live Discord messages per channel — edited in-place as Claude streams output. */
235
+ liveMessages = new Map();
234
236
  /** Start (or reset) the typing indicator for a meta-agent–routed channel. */
235
237
  startMetaAgentTyping(channelId, channel) {
236
238
  this.stopMetaAgentTyping(channelId);
@@ -245,6 +247,96 @@ export class CcDiscordBot {
245
247
  this.metaAgentTypingTimers.delete(channelId);
246
248
  }
247
249
  }
250
+ /**
251
+ * Get or create a live Discord message for `channelId`.
252
+ * If a live message already exists (e.g. from a tool_start), returns it (for reuse).
253
+ * Otherwise sends `initial` to create the placeholder and stops the typing indicator.
254
+ */
255
+ async startOrGetLiveMessage(channelId, initial) {
256
+ const existing = this.liveMessages.get(channelId);
257
+ if (existing)
258
+ return existing.msg;
259
+ const channel = await this.getChannel(channelId);
260
+ if (!channel)
261
+ return null;
262
+ this.stopMetaAgentTyping(channelId);
263
+ try {
264
+ const msg = await channel.send(initial || "▋");
265
+ this.liveMessages.set(channelId, { msg, lastEditAt: Date.now(), editTimer: null, pendingText: null });
266
+ return msg;
267
+ }
268
+ catch (err) {
269
+ console.warn(`[bot] startOrGetLiveMessage failed (${channelId}):`, err.message);
270
+ return null;
271
+ }
272
+ }
273
+ /**
274
+ * Throttled edit of the live message for `channelId`.
275
+ * Safe to call on every token/chunk — edits are coalesced at 750ms intervals.
276
+ * The latest `text` always wins (older pending updates are replaced).
277
+ */
278
+ updateLiveMessage(channelId, text) {
279
+ const entry = this.liveMessages.get(channelId);
280
+ if (!entry)
281
+ return;
282
+ entry.pendingText = text;
283
+ if (entry.editTimer)
284
+ return; // timer already scheduled; will pick up pendingText when it fires
285
+ const msUntilSafe = Math.max(0, 750 - (Date.now() - entry.lastEditAt));
286
+ entry.editTimer = setTimeout(async () => {
287
+ entry.editTimer = null;
288
+ const t = entry.pendingText;
289
+ if (t == null)
290
+ return;
291
+ entry.pendingText = null;
292
+ try {
293
+ await entry.msg.edit(t.slice(0, 1990));
294
+ entry.lastEditAt = Date.now();
295
+ }
296
+ catch {
297
+ this.liveMessages.delete(channelId); // message deleted or perms lost — give up
298
+ }
299
+ }, msUntilSafe);
300
+ }
301
+ /**
302
+ * Final edit of the live message — applies `text` without cursor, clears tracker.
303
+ * If no live message exists, falls back to sendWithFileDetection (new message).
304
+ * Overflow past 2000 chars spawns continuation messages via sendToChannelById.
305
+ */
306
+ async finalizeLiveMessage(channelId, text) {
307
+ const entry = this.liveMessages.get(channelId);
308
+ if (entry?.editTimer) {
309
+ clearTimeout(entry.editTimer);
310
+ entry.editTimer = null;
311
+ }
312
+ this.liveMessages.delete(channelId);
313
+ if (!entry) {
314
+ if (text.trim())
315
+ await this.sendWithFileDetection(channelId, text);
316
+ return;
317
+ }
318
+ if (!text.trim()) {
319
+ // Nothing to show — delete the placeholder message
320
+ entry.msg.delete().catch(() => { });
321
+ return;
322
+ }
323
+ const formatted = formatForDiscord(text);
324
+ const chunks = splitLongMessage(formatted);
325
+ try {
326
+ await entry.msg.edit(chunks[0]);
327
+ for (const chunk of chunks.slice(1)) {
328
+ if (chunk.trim())
329
+ await this.sendToChannelById(channelId, chunk);
330
+ }
331
+ }
332
+ catch {
333
+ // Message was deleted or edit failed — send as new messages
334
+ for (const chunk of chunks) {
335
+ if (chunk.trim())
336
+ await this.sendWithFileDetection(channelId, chunk).catch(() => { });
337
+ }
338
+ }
339
+ }
248
340
  /** Session key: "channelId" or "channelId:threadId" for threads */
249
341
  sessionKey(channelId, threadId) {
250
342
  return threadId ? `${channelId}:${threadId}` : channelId;
@@ -16,7 +16,7 @@ import { spawn, execSync } from "child_process";
16
16
  import { existsSync, mkdirSync, writeFileSync } from "fs";
17
17
  import { join } from "path";
18
18
  import { homedir } from "os";
19
- import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
19
+ import { CC_DISCORD_WORKSPACE_ROOT, TIMING, DISCORD_INSTANCE_KEY, discordMetaInputKey, discordChatOutgoing, metaStreamChannel as ccWireMetaStreamChannel, metaLogKey as ccWireMetaLogKey, } from "@gonzih/cc-wire";
20
20
  const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
21
21
  /**
22
22
  * Returns the path to the workspace for the given namespace.
@@ -193,17 +193,28 @@ function wireStdoutToRedis(proc, ns, wire) {
193
193
  }
194
194
  }
195
195
  else if (type === "tool_use") {
196
+ const toolName = parsed.name ?? "tool";
196
197
  structuredEvent = {
197
198
  type: "tool_use",
198
- name: parsed.name ?? "",
199
+ name: toolName,
199
200
  input: parsed.input ?? {},
200
201
  };
202
+ // Ephemeral signal: notifier shows tool activity overlay in the live message
203
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
204
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
205
+ content: toolName, event: "tool_start", timestamp: new Date().toISOString(), chatId: 0,
206
+ })).catch(() => { });
201
207
  }
202
208
  else if (type === "tool_result") {
203
209
  structuredEvent = {
204
210
  type: "tool_result",
205
211
  content: parsed.content ?? "",
206
212
  };
213
+ // Ephemeral signal: tool finished, notifier can restart finalize timer
214
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
215
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
216
+ content: "", event: "tool_end", timestamp: new Date().toISOString(), chatId: 0,
217
+ })).catch(() => { });
207
218
  }
208
219
  else if (type === "result") {
209
220
  const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
@@ -212,7 +223,7 @@ function wireStdoutToRedis(proc, ns, wire) {
212
223
  result: resultText,
213
224
  is_error: parsed.is_error ?? false,
214
225
  };
215
- if (resultText) {
226
+ if (resultText && !parsed.is_error) {
216
227
  const msg = {
217
228
  id: crypto.randomUUID(),
218
229
  source: "claude",
@@ -221,10 +232,44 @@ function wireStdoutToRedis(proc, ns, wire) {
221
232
  timestamp: new Date().toISOString(),
222
233
  chatId: 0,
223
234
  };
224
- wire.discord.publishOutgoing(ns, msg).catch((err) => {
235
+ // Publish text first (logs to chat history), then signal done for immediate finalization
236
+ wire.discord.publishOutgoing(ns, msg).then(() => {
237
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
238
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
239
+ content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
240
+ })).catch(() => { });
241
+ }).catch((err) => {
225
242
  console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
243
+ // Still signal done even if text publish failed
244
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
245
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
246
+ content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
247
+ })).catch(() => { });
226
248
  });
227
249
  }
250
+ else if (parsed.is_error) {
251
+ // Error result: publish error text and signal done
252
+ const errMsg = {
253
+ id: crypto.randomUUID(),
254
+ source: "claude",
255
+ role: "assistant",
256
+ content: `⚠️ Error: ${resultText || "unknown error"}`,
257
+ timestamp: new Date().toISOString(),
258
+ chatId: 0,
259
+ };
260
+ wire.discord.publishOutgoing(ns, errMsg).catch(() => { });
261
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
262
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
263
+ content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
264
+ })).catch(() => { });
265
+ }
266
+ else {
267
+ // Empty result — just signal done
268
+ rawRedis.publish(discordChatOutgoing(ns), JSON.stringify({
269
+ id: crypto.randomUUID(), source: "claude", role: "assistant",
270
+ content: "", event: "done", timestamp: new Date().toISOString(), chatId: 0,
271
+ })).catch(() => { });
272
+ }
228
273
  }
229
274
  else {
230
275
  structuredEvent = parsed;
package/dist/notifier.js CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { createHash } from "crypto";
14
14
  import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, dedupKey, } from "@gonzih/cc-wire";
15
- import { splitLongMessage, stripAnsi } from "./formatter.js";
15
+ import { stripAnsi } from "./formatter.js";
16
16
  /**
17
17
  * Parse an `eval_report` object embedded in a raw notification JSON string.
18
18
  * Returns null when the field is absent, malformed, or the input is not JSON.
@@ -270,25 +270,37 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
270
270
  log("info", `psubscribed to ${outgoingPattern}`);
271
271
  }
272
272
  });
273
- // Buffer for meta-agent streaming output — debounced before sending to Discord
274
- const metaAgentBuffers = new Map();
275
- function flushMetaAgentBuffer(ns, targetChannelId) {
276
- const loopThreadId = bot.getLoopThreadId(targetChannelId);
277
- bot.stopMetaAgentTyping(loopThreadId ?? targetChannelId);
278
- const buf = metaAgentBuffers.get(ns);
279
- if (!buf || !buf.text.trim())
273
+ const liveStates = new Map();
274
+ function getLiveState(ns, targetChannelId) {
275
+ let state = liveStates.get(ns);
276
+ if (!state) {
277
+ state = { text: "", toolStatus: "", targetChannelId, starting: false, liveStarted: false, finalTimer: null };
278
+ liveStates.set(ns, state);
279
+ }
280
+ return state;
281
+ }
282
+ async function finalizeState(ns, state) {
283
+ if (state.finalTimer) {
284
+ clearTimeout(state.finalTimer);
285
+ state.finalTimer = null;
286
+ }
287
+ liveStates.delete(ns);
288
+ const deliverTo = bot.getLoopThreadId(state.targetChannelId) ?? state.targetChannelId;
289
+ bot.stopMetaAgentTyping(deliverTo);
290
+ const trimmed = state.text.trim();
291
+ if (!trimmed && !state.liveStarted)
280
292
  return;
281
- const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
282
- buf.text = "";
283
- buf.timer = null;
284
- // During an active loop, route meta-agent output to the thread rather than main channel
285
- const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
286
- const chunks = splitLongMessage(text);
287
- for (const chunk of chunks) {
288
- bot.sendWithFileDetection(deliverTo, chunk).catch((err) => {
289
- log("warn", `meta-agent flush sendWithFileDetection failed (ns=${ns}):`, err.message);
293
+ const fullText = trimmed ? `← [${ns}]\n${stripAnsi(trimmed)}` : "";
294
+ await bot.finalizeLiveMessage(deliverTo, fullText);
295
+ }
296
+ function scheduleFinal(ns, state) {
297
+ if (state.finalTimer)
298
+ clearTimeout(state.finalTimer);
299
+ state.finalTimer = setTimeout(() => {
300
+ finalizeState(ns, state).catch((err) => {
301
+ log("warn", `meta-agent finalize failed (ns=${ns}):`, err.message);
290
302
  });
291
- }
303
+ }, TIMING.META_AGENT_FLUSH_DELAY_MS);
292
304
  }
293
305
  sub.on("pmessage", (pattern, channel, message) => {
294
306
  void pattern;
@@ -300,28 +312,78 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
300
312
  catch {
301
313
  return;
302
314
  }
303
- if (parsed.source !== "claude")
304
- return;
305
- const content = parsed.content;
306
- if (!content)
315
+ if (parsed?.source !== "claude")
307
316
  return;
308
- // For the primary namespace: deliver to the primary Discord channel.
309
- // For other (routed) namespaces: only deliver to explicitly registered channelIds.
310
317
  const targetChannelId = routedChannelIds.get(ns) ??
311
318
  (ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
312
319
  if (targetChannelId == null) {
313
- log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
320
+ log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping`);
314
321
  return;
315
322
  }
316
- let buf = metaAgentBuffers.get(ns);
317
- if (!buf) {
318
- buf = { text: "", timer: null };
319
- metaAgentBuffers.set(ns, buf);
323
+ const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
324
+ const state = getLiveState(ns, targetChannelId);
325
+ const event = parsed.event;
326
+ // tool_start: show activity overlay, suspend finalize timer
327
+ if (event === "tool_start") {
328
+ const toolName = parsed.content || "tool";
329
+ state.toolStatus = toolName;
330
+ if (state.finalTimer) {
331
+ clearTimeout(state.finalTimer);
332
+ state.finalTimer = null;
333
+ }
334
+ if (state.liveStarted) {
335
+ const textPart = state.text.trim() ? `← [${ns}]\n${stripAnsi(state.text.trim())}\n\n` : "";
336
+ bot.updateLiveMessage(deliverTo, `${textPart}⚙️ \`${toolName}\`...`);
337
+ }
338
+ else if (!state.starting) {
339
+ state.starting = true;
340
+ bot.startOrGetLiveMessage(deliverTo, `⚙️ \`${toolName}\`...`).then((msg) => {
341
+ state.starting = false;
342
+ if (msg)
343
+ state.liveStarted = true;
344
+ }).catch(() => { state.starting = false; });
345
+ }
346
+ return;
347
+ }
348
+ // tool_end: clear overlay, resume finalize timer if text is pending
349
+ if (event === "tool_end") {
350
+ state.toolStatus = "";
351
+ if (state.text.trim())
352
+ scheduleFinal(ns, state);
353
+ return;
354
+ }
355
+ // done: immediate finalization (fired by meta-agent-manager after result event)
356
+ if (event === "done") {
357
+ finalizeState(ns, state).catch((err) => {
358
+ log("warn", `meta-agent done finalize failed (ns=${ns}):`, err.message);
359
+ });
360
+ return;
361
+ }
362
+ // Regular text chunk: accumulate, update live message
363
+ const content = parsed.content;
364
+ if (!content)
365
+ return;
366
+ state.toolStatus = ""; // text arrived — clear any tool overlay
367
+ state.text += (state.text ? "\n" : "") + content;
368
+ const textDisplay = `← [${ns}]\n${stripAnsi(state.text)} ▋`;
369
+ if (!state.liveStarted) {
370
+ if (!state.starting) {
371
+ state.starting = true;
372
+ // startOrGetLiveMessage reuses existing message if tool_start created one
373
+ bot.startOrGetLiveMessage(deliverTo, textDisplay).then((msg) => {
374
+ state.starting = false;
375
+ if (msg) {
376
+ state.liveStarted = true;
377
+ bot.updateLiveMessage(deliverTo, textDisplay);
378
+ }
379
+ }).catch(() => { state.starting = false; });
380
+ }
381
+ // Text is buffered in state.text; applied when startOrGetLiveMessage resolves
382
+ }
383
+ else {
384
+ bot.updateLiveMessage(deliverTo, textDisplay);
320
385
  }
321
- buf.text += (buf.text ? "\n" : "") + content;
322
- if (buf.timer)
323
- clearTimeout(buf.timer);
324
- buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), TIMING.META_AGENT_FLUSH_DELAY_MS);
386
+ scheduleFinal(ns, state);
325
387
  });
326
388
  // Poll discordNotify(ns) LIST every 5 seconds — covers primary + all routed namespaces.
327
389
  const MAX_PER_CYCLE = 20;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.40",
3
+ "version": "0.2.41",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {