@gonzih/cc-discord 0.2.40 → 0.2.42

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;
@@ -298,15 +343,15 @@ function spawnPersistentSession(ns, token, wire, onExit, onOutput) {
298
343
  const claudeBin = resolveClaude();
299
344
  const env = buildEnv(token);
300
345
  console.log(`[meta-agent-manager] spawning persistent session (ns=${ns})`);
301
- // DO NOT ADD -p OR --input-format stream-json HERE.
302
- // With -p + --input-format stream-json, Claude buffers all stdin until EOF before responding.
303
- // Since the stdin pipe is never closed (it stays open for subsequent messages), Claude
304
- // waits forever and never produces output. Interactive mode (no -p) reads one line at a
305
- // time and responds immediately. Verified by direct test: `echo msg | claude --continue
306
- // --output-format stream-json ...` responds correctly; the -p variant does not.
346
+ // --input-format stream-json: Claude reads newline-delimited JSON messages from stdin.
347
+ // Each message must be: {"type":"user","message":{"role":"user","content":"..."}}
348
+ // Claude responds immediately to each message and stays alive waiting for the next one.
349
+ // DO NOT omit --input-format stream-json: without it, Claude ignores stdin when the
350
+ // pipe stays open (no TTY), producing no output until stdin is closed (EOF).
307
351
  const proc = spawn(claudeBin, [
308
352
  "--continue",
309
353
  "--output-format", "stream-json",
354
+ "--input-format", "stream-json",
310
355
  "--verbose",
311
356
  "--dangerously-skip-permissions",
312
357
  ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
@@ -357,13 +402,14 @@ export function createMetaAgentManager() {
357
402
  * Resets the inactivity timer so the watchdog doesn't kill a session
358
403
  * that just received a message but hasn't responded yet.
359
404
  */
360
- function writeToStdin(ns, line) {
405
+ function writeToStdin(ns, content) {
361
406
  const session = sessions.get(ns);
362
407
  if (!session)
363
408
  return;
364
409
  try {
365
- // Interactive mode: plain text line, Claude reads it as user input
366
- session.proc.stdin.write(`${line}\n`);
410
+ // --input-format stream-json: each message must be a newline-delimited JSON object.
411
+ const jsonMsg = JSON.stringify({ type: "user", message: { role: "user", content } });
412
+ session.proc.stdin.write(`${jsonMsg}\n`);
367
413
  // Reset inactivity timer — the session now has SESSION_INACTIVITY_MS to respond
368
414
  session.lastOutputAt = Date.now();
369
415
  }
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.42",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {