@gonzih/cc-discord 0.2.39 → 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;
@@ -65,21 +65,6 @@ export interface MetaAgentManager {
65
65
  /** Write a raw line to the stdin of the running session, if any. */
66
66
  sendToSession: (ns: string, line: string) => void;
67
67
  }
68
- /**
69
- * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
70
- *
71
- * On first message for a namespace:
72
- * 1. ensureWorkspace + injectMcp
73
- * 2. Drain any pending Redis queue entries to stdin
74
- * 3. Write the new message to stdin
75
- *
76
- * On subsequent messages: write directly to stdin of the running process.
77
- *
78
- * On process exit: remove from sessions map. Next message triggers a respawn.
79
- *
80
- * The 3-second poll loop drains any messages that arrived while a session was
81
- * starting up or temporarily unavailable.
82
- */
83
68
  export declare function createMetaAgentManager(): MetaAgentManager;
84
69
  /**
85
70
  * Migrate the old cc-agent meta input key to the new cc-discord key.
@@ -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;
@@ -293,7 +338,7 @@ export function spawnSession(ns, message, token, wire) {
293
338
  * Stdout is wired to Redis via wireStdoutToRedis.
294
339
  * Returns the ChildProcess.
295
340
  */
296
- function spawnPersistentSession(ns, token, wire, onExit) {
341
+ function spawnPersistentSession(ns, token, wire, onExit, onOutput) {
297
342
  const wsPath = workspacePath(ns);
298
343
  const claudeBin = resolveClaude();
299
344
  const env = buildEnv(token);
@@ -312,6 +357,9 @@ function spawnPersistentSession(ns, token, wire, onExit) {
312
357
  ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
313
358
  proc.stdin.setDefaultEncoding("utf8");
314
359
  wireStdoutToRedis(proc, ns, wire);
360
+ if (onOutput) {
361
+ proc.stdout.on("data", onOutput);
362
+ }
315
363
  proc.on("exit", (code) => {
316
364
  console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
317
365
  onExit();
@@ -337,15 +385,22 @@ function spawnPersistentSession(ns, token, wire, onExit) {
337
385
  * The 3-second poll loop drains any messages that arrived while a session was
338
386
  * starting up or temporarily unavailable.
339
387
  */
388
+ /** Kill a session after this many ms of no stdout (or no new stdin). */
389
+ const SESSION_INACTIVITY_MS = 5 * 60_000; // 5 minutes
340
390
  export function createMetaAgentManager() {
341
391
  let pollInterval = null;
392
+ let watchdogInterval = null;
342
393
  /** One persistent ChildProcess per namespace. */
343
394
  const sessions = new Map();
344
395
  /** Namespaces currently being set up (workspace clone / first spawn). */
345
396
  const startingUp = new Set();
397
+ /** Wire reference needed for watchdog notifications. Set in startPolling. */
398
+ let wireRef = null;
346
399
  /**
347
400
  * Write a line to the stdin of the persistent session for ns.
348
401
  * Silently no-ops if no session exists.
402
+ * Resets the inactivity timer so the watchdog doesn't kill a session
403
+ * that just received a message but hasn't responded yet.
349
404
  */
350
405
  function writeToStdin(ns, line) {
351
406
  const session = sessions.get(ns);
@@ -354,11 +409,54 @@ export function createMetaAgentManager() {
354
409
  try {
355
410
  // Interactive mode: plain text line, Claude reads it as user input
356
411
  session.proc.stdin.write(`${line}\n`);
412
+ // Reset inactivity timer — the session now has SESSION_INACTIVITY_MS to respond
413
+ session.lastOutputAt = Date.now();
357
414
  }
358
415
  catch (err) {
359
416
  console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
360
417
  }
361
418
  }
419
+ /**
420
+ * Watchdog: scan all sessions every 60s. If any session has been silent
421
+ * (no stdout and no new stdin) for SESSION_INACTIVITY_MS, kill it and
422
+ * notify Discord so the user can see why the session went quiet.
423
+ */
424
+ function startWatchdog() {
425
+ if (watchdogInterval)
426
+ return;
427
+ watchdogInterval = setInterval(() => {
428
+ if (!wireRef)
429
+ return;
430
+ const now = Date.now();
431
+ for (const [ns, session] of sessions) {
432
+ const idleMs = now - session.lastOutputAt;
433
+ if (idleMs < SESSION_INACTIVITY_MS)
434
+ continue;
435
+ const idleMin = Math.round(idleMs / 60_000);
436
+ console.warn(`[meta-agent-manager] watchdog: ns=${ns} idle ${idleMin}m, killing stuck session`);
437
+ // Notify Discord before killing so the user knows what happened
438
+ const alertMsg = {
439
+ id: crypto.randomUUID(),
440
+ source: "claude",
441
+ role: "assistant",
442
+ content: `⚠️ Session for **${ns}** was idle for ${idleMin} minutes (likely stuck on a tool call). Killed and removed — send any message to restart.`,
443
+ timestamp: new Date().toISOString(),
444
+ chatId: 0,
445
+ };
446
+ wireRef.discord.publishOutgoing(ns, alertMsg).catch((err) => {
447
+ console.warn(`[meta-agent-manager] watchdog publishOutgoing failed (ns=${ns}):`, err.message);
448
+ });
449
+ try {
450
+ session.proc.stdin.end();
451
+ session.proc.kill("SIGTERM");
452
+ }
453
+ catch {
454
+ // ignore kill errors
455
+ }
456
+ sessions.delete(ns);
457
+ }
458
+ }, 60_000);
459
+ }
362
460
  /**
363
461
  * Drain all pending messages from the Redis input queue into the session's stdin.
364
462
  */
@@ -405,12 +503,19 @@ export function createMetaAgentManager() {
405
503
  const wsPath = workspacePath(ns);
406
504
  await ensureWorkspace(ns, repoUrl);
407
505
  injectMcp(ns, wsPath, token);
506
+ // Placeholder — filled in after proc is known so the onOutput closure can update it
507
+ let session;
408
508
  const proc = spawnPersistentSession(ns, token, wire, () => {
409
509
  // On exit: remove from map so next message triggers a respawn
410
510
  sessions.delete(ns);
411
511
  console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
512
+ }, () => {
513
+ // Reset inactivity timer on any stdout data
514
+ if (session)
515
+ session.lastOutputAt = Date.now();
412
516
  });
413
- sessions.set(ns, { proc, ns });
517
+ session = { proc, ns, lastOutputAt: Date.now() };
518
+ sessions.set(ns, session);
414
519
  // Drain any messages that arrived before this session started
415
520
  await drainQueue(ns, wire);
416
521
  // Write the triggering message last (after queue drain, in order)
@@ -443,6 +548,8 @@ export function createMetaAgentManager() {
443
548
  startPolling(wire, getNamespaces, instanceId) {
444
549
  if (pollInterval)
445
550
  return; // already running
551
+ wireRef = wire;
552
+ startWatchdog();
446
553
  pollInterval = setInterval(() => {
447
554
  const namespaces = getNamespaces();
448
555
  if (namespaces.length === 0)
@@ -499,6 +606,10 @@ export function createMetaAgentManager() {
499
606
  pollInterval = null;
500
607
  console.log("[meta-agent-manager] polling stopped");
501
608
  }
609
+ if (watchdogInterval) {
610
+ clearInterval(watchdogInterval);
611
+ watchdogInterval = null;
612
+ }
502
613
  // Kill all active sessions
503
614
  for (const [ns, session] of sessions) {
504
615
  try {
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,30 +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())
280
- return;
281
- const text = `← [${ns}] ` + stripAnsi(buf.text.trim());
282
- if (text.length < 30) {
283
- buf.text = "";
284
- buf.timer = null;
285
- return;
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);
286
279
  }
287
- buf.text = "";
288
- buf.timer = null;
289
- // During an active loop, route meta-agent output to the thread rather than main channel
290
- const deliverTo = bot.getLoopThreadId(targetChannelId) ?? targetChannelId;
291
- const chunks = splitLongMessage(text);
292
- for (const chunk of chunks) {
293
- bot.sendWithFileDetection(deliverTo, chunk).catch((err) => {
294
- log("warn", `meta-agent flush sendWithFileDetection failed (ns=${ns}):`, err.message);
295
- });
280
+ return state;
281
+ }
282
+ async function finalizeState(ns, state) {
283
+ if (state.finalTimer) {
284
+ clearTimeout(state.finalTimer);
285
+ state.finalTimer = null;
296
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)
292
+ return;
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);
302
+ });
303
+ }, TIMING.META_AGENT_FLUSH_DELAY_MS);
297
304
  }
298
305
  sub.on("pmessage", (pattern, channel, message) => {
299
306
  void pattern;
@@ -305,28 +312,78 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
305
312
  catch {
306
313
  return;
307
314
  }
308
- if (parsed.source !== "claude")
315
+ if (parsed?.source !== "claude")
309
316
  return;
310
- const content = parsed.content;
311
- if (!content)
312
- return;
313
- // For the primary namespace: deliver to the primary Discord channel.
314
- // For other (routed) namespaces: only deliver to explicitly registered channelIds.
315
317
  const targetChannelId = routedChannelIds.get(ns) ??
316
318
  (ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
317
319
  if (targetChannelId == null) {
318
- log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping line`);
320
+ log("warn", `meta-agent output: no channelId for namespace=${ns}, dropping`);
319
321
  return;
320
322
  }
321
- let buf = metaAgentBuffers.get(ns);
322
- if (!buf) {
323
- buf = { text: "", timer: null };
324
- 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);
325
385
  }
326
- buf.text += (buf.text ? "\n" : "") + content;
327
- if (buf.timer)
328
- clearTimeout(buf.timer);
329
- buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), TIMING.META_AGENT_FLUSH_DELAY_MS);
386
+ scheduleFinal(ns, state);
330
387
  });
331
388
  // Poll discordNotify(ns) LIST every 5 seconds — covers primary + all routed namespaces.
332
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.39",
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": {