@gonzih/cc-discord 0.2.48 → 0.2.50

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.
@@ -4,7 +4,7 @@
4
4
  * v0.2.0 channels (discord-scoped):
5
5
  * cca:discord:notify:{ns} — job completion notifications → forward to Discord channel
6
6
  * cca:chat:incoming:{ns} — messages from the web UI → echo to Discord + feed to meta-agent
7
- * cca:discord:chat:outgoing:{ns} — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
7
+ * cca:discord:chat:outgoing:{ns} — meta-agent stdout lines (source=claude/codex) → buffer+debounce → Discord
8
8
  *
9
9
  * All messages (Discord incoming, Claude responses) are also written to:
10
10
  * cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
@@ -23,7 +23,7 @@ export interface EvalReport {
23
23
  import type { CcDiscordBot } from "./bot.js";
24
24
  export interface ChatMessage {
25
25
  id: string;
26
- source: "discord" | "ui" | "claude" | "cc-tg" | "cc-discord";
26
+ source: "discord" | "ui" | "claude" | "codex" | "cc-tg" | "cc-discord";
27
27
  role: "user" | "assistant" | "tool";
28
28
  content: string;
29
29
  timestamp: string;
package/dist/notifier.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * v0.2.0 channels (discord-scoped):
5
5
  * cca:discord:notify:{ns} — job completion notifications → forward to Discord channel
6
6
  * cca:chat:incoming:{ns} — messages from the web UI → echo to Discord + feed to meta-agent
7
- * cca:discord:chat:outgoing:{ns} — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
7
+ * cca:discord:chat:outgoing:{ns} — meta-agent stdout lines (source=claude/codex) → buffer+debounce → Discord
8
8
  *
9
9
  * All messages (Discord incoming, Claude responses) are also written to:
10
10
  * cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
@@ -270,15 +270,60 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
270
270
  log("info", `psubscribed to ${outgoingPattern}`);
271
271
  }
272
272
  });
273
+ const TOOL_SLOT = "tools";
274
+ const RESPONSE_SLOT = "response";
275
+ const showToolUsage = () => /^(1|true|yes)$/i.test(process.env.CC_DISCORD_SHOW_TOOL_USAGE ?? "");
273
276
  const liveStates = new Map();
274
277
  function getLiveState(ns, targetChannelId) {
275
278
  let state = liveStates.get(ns);
276
279
  if (!state) {
277
- state = { text: "", toolStatus: "", targetChannelId, starting: false, liveStarted: false, finalTimer: null, pendingDone: false };
280
+ state = {
281
+ text: "",
282
+ toolLog: [],
283
+ activeTool: "",
284
+ targetChannelId,
285
+ responseStarting: false,
286
+ responseStarted: false,
287
+ toolStarting: false,
288
+ toolStarted: false,
289
+ finalTimer: null,
290
+ };
278
291
  liveStates.set(ns, state);
279
292
  }
280
293
  return state;
281
294
  }
295
+ function toolLogText(ns, state, live) {
296
+ const visible = state.toolLog.slice(0, 20);
297
+ const body = visible.length > 0 ? visible.join("\n") : "waiting for tool activity";
298
+ return `← [${ns}] tools\n${body}${live ? " ▋" : ""}`;
299
+ }
300
+ function ensureToolLogMessage(ns, deliverTo, state) {
301
+ if (state.toolStarted) {
302
+ bot.updateLiveMessage(deliverTo, toolLogText(ns, state, true), TOOL_SLOT);
303
+ return Promise.resolve();
304
+ }
305
+ if (state.toolStarting)
306
+ return Promise.resolve();
307
+ state.toolStarting = true;
308
+ return bot.startOrGetLiveMessage(deliverTo, toolLogText(ns, state, true), TOOL_SLOT).then((msg) => {
309
+ state.toolStarting = false;
310
+ if (msg) {
311
+ state.toolStarted = true;
312
+ bot.updateLiveMessage(deliverTo, toolLogText(ns, state, true), TOOL_SLOT);
313
+ }
314
+ }).catch(() => {
315
+ state.toolStarting = false;
316
+ });
317
+ }
318
+ function replaceNewestActiveTool(state, replacement) {
319
+ const idx = state.toolLog.findIndex((line) => line.startsWith("⚙️"));
320
+ if (idx >= 0) {
321
+ state.toolLog[idx] = replacement;
322
+ }
323
+ else {
324
+ state.toolLog.unshift(replacement);
325
+ }
326
+ }
282
327
  async function finalizeState(ns, state) {
283
328
  if (state.finalTimer) {
284
329
  clearTimeout(state.finalTimer);
@@ -288,10 +333,18 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
288
333
  const deliverTo = bot.getLoopThreadId(state.targetChannelId) ?? state.targetChannelId;
289
334
  bot.stopMetaAgentTyping(deliverTo);
290
335
  const trimmed = state.text.trim();
291
- if (!trimmed && !state.liveStarted)
336
+ const toolText = state.toolLog.length > 0 ? toolLogText(ns, state, false) : "";
337
+ if (showToolUsage() && (state.toolStarted || state.toolLog.length > 0)) {
338
+ await bot.finalizeLiveMessage(deliverTo, toolText, TOOL_SLOT);
339
+ }
340
+ if (!trimmed && !state.responseStarted)
292
341
  return;
293
342
  const fullText = trimmed ? `← [${ns}]\n${stripAnsi(trimmed)}` : "";
294
- await bot.finalizeLiveMessage(deliverTo, fullText);
343
+ if (fullText.length > 0 && fullText.length < 30) {
344
+ await bot.finalizeLiveMessage(deliverTo, "", RESPONSE_SLOT);
345
+ return;
346
+ }
347
+ await bot.finalizeLiveMessage(deliverTo, fullText, RESPONSE_SLOT);
295
348
  }
296
349
  function scheduleFinal(ns, state) {
297
350
  if (state.finalTimer)
@@ -312,7 +365,7 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
312
365
  catch {
313
366
  return;
314
367
  }
315
- if (parsed?.source !== "claude")
368
+ if (parsed?.source !== "claude" && parsed?.source !== "codex")
316
369
  return;
317
370
  const targetChannelId = routedChannelIds.get(ns) ??
318
371
  (ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
@@ -326,52 +379,33 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
326
379
  // tool_start: show activity overlay, suspend finalize timer
327
380
  if (event === "tool_start") {
328
381
  const toolName = parsed.content || "tool";
329
- state.toolStatus = toolName;
382
+ state.activeTool = toolName;
330
383
  if (state.finalTimer) {
331
384
  clearTimeout(state.finalTimer);
332
385
  state.finalTimer = null;
333
386
  }
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; });
387
+ if (showToolUsage()) {
388
+ state.toolLog.unshift(`⚙️ \`${toolName}\`...`);
389
+ void ensureToolLogMessage(ns, deliverTo, state);
345
390
  }
346
391
  return;
347
392
  }
348
393
  // tool_end: clear overlay, resume finalize timer if text is pending
349
394
  if (event === "tool_end") {
350
- state.toolStatus = "";
395
+ const toolName = state.activeTool || "tool";
396
+ state.activeTool = "";
397
+ if (showToolUsage()) {
398
+ replaceNewestActiveTool(state, `✓ \`${toolName}\``);
399
+ }
400
+ if (showToolUsage() && state.toolStarted) {
401
+ bot.updateLiveMessage(deliverTo, toolLogText(ns, state, true), TOOL_SLOT);
402
+ }
351
403
  if (state.text.trim())
352
404
  scheduleFinal(ns, state);
353
405
  return;
354
406
  }
355
- // rate_limit: delivered after the current turn's "done" — send as a standalone message,
356
- // never accumulate into live state text (which already finalized above via "done").
357
- if (event === "rate_limit") {
358
- const alertContent = parsed.content ?? "";
359
- if (alertContent) {
360
- bot.sendToChannelById(deliverTo, `← [${ns}]\n${alertContent}`).catch((err) => {
361
- log("warn", `rate_limit alert send failed (ns=${ns}):`, err.message);
362
- });
363
- }
364
- return;
365
- }
366
407
  // done: immediate finalization (fired by meta-agent-manager after result event)
367
408
  if (event === "done") {
368
- if (state.starting) {
369
- // startOrGetLiveMessage is still in flight — defer finalization to its .then() callback
370
- // so we don't race: finalizeState runs before the Discord message exists → fallback
371
- // sends a second plain message, leaving the live message orphaned with ▋.
372
- state.pendingDone = true;
373
- return;
374
- }
375
409
  finalizeState(ns, state).catch((err) => {
376
410
  log("warn", `meta-agent done finalize failed (ns=${ns}):`, err.message);
377
411
  });
@@ -381,31 +415,30 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
381
415
  const content = parsed.content;
382
416
  if (!content)
383
417
  return;
384
- state.toolStatus = ""; // text arrived clear any tool overlay
385
- state.text += (state.text ? "\n" : "") + content;
418
+ state.text += parsed.source === "codex"
419
+ ? content
420
+ : (state.text ? "\n" : "") + content;
386
421
  const textDisplay = `← [${ns}]\n${stripAnsi(state.text)} ▋`;
387
- if (!state.liveStarted) {
388
- if (!state.starting) {
389
- state.starting = true;
390
- // startOrGetLiveMessage reuses existing message if tool_start created one
391
- bot.startOrGetLiveMessage(deliverTo, textDisplay).then((msg) => {
392
- state.starting = false;
422
+ if (!state.responseStarted) {
423
+ if (!state.responseStarting) {
424
+ state.responseStarting = true;
425
+ const beforeResponse = showToolUsage()
426
+ ? ensureToolLogMessage(ns, deliverTo, state)
427
+ : Promise.resolve();
428
+ beforeResponse.then(() => {
429
+ return bot.startOrGetLiveMessage(deliverTo, textDisplay, RESPONSE_SLOT);
430
+ }).then((msg) => {
431
+ state.responseStarting = false;
393
432
  if (msg) {
394
- state.liveStarted = true;
395
- bot.updateLiveMessage(deliverTo, textDisplay);
396
- }
397
- // done arrived while we were creating the message — finalize now
398
- if (state.pendingDone) {
399
- finalizeState(ns, state).catch((err) => {
400
- log("warn", `meta-agent deferred done finalize failed (ns=${ns}):`, err.message);
401
- });
433
+ state.responseStarted = true;
434
+ bot.updateLiveMessage(deliverTo, textDisplay, RESPONSE_SLOT);
402
435
  }
403
- }).catch(() => { state.starting = false; });
436
+ }).catch(() => { state.responseStarting = false; });
404
437
  }
405
438
  // Text is buffered in state.text; applied when startOrGetLiveMessage resolves
406
439
  }
407
440
  else {
408
- bot.updateLiveMessage(deliverTo, textDisplay);
441
+ bot.updateLiveMessage(deliverTo, textDisplay, RESPONSE_SLOT);
409
442
  }
410
443
  scheduleFinal(ns, state);
411
444
  });
package/dist/tokens.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
5
  * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
6
  *
7
- * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
7
+ * cc-tg token pool rotates independently from cc-discord's local session pool.
8
8
  */
9
9
  /**
10
10
  * Load tokens from env vars. Called on startup; also re-callable in tests.
package/dist/tokens.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Supports CLAUDE_CODE_OAUTH_TOKENS (comma-separated list of tokens).
5
5
  * Falls back to CLAUDE_CODE_OAUTH_TOKEN for single-token / backwards compat.
6
6
  *
7
- * cc-tg token pool rotates independently from cc-agent's pool. No coordination between them.
7
+ * cc-tg token pool rotates independently from cc-discord's local session pool.
8
8
  */
9
9
  let tokens = [];
10
10
  let currentIndex = 0;
package/dist/voice.js CHANGED
@@ -4,22 +4,36 @@
4
4
  */
5
5
  import { execFile } from "child_process";
6
6
  import { promisify } from "util";
7
- import { existsSync, createWriteStream } from "fs";
8
- import { unlink, readFile, access } from "fs/promises";
7
+ import { existsSync, createWriteStream, readdirSync } from "fs";
8
+ import { unlink, readFile, access, mkdir } from "fs/promises";
9
9
  import { tmpdir } from "os";
10
10
  import { join } from "path";
11
11
  import https from "https";
12
12
  import http from "http";
13
13
  const execFileAsync = promisify(execFile);
14
- // Env var overrides allow test injection: WHISPER_BIN, FFMPEG_BIN, WHISPER_MODEL
14
+ // Env var overrides allow test injection: WHISPER_BIN, FFMPEG_BIN, WHISPER_MODEL.
15
+ // WHISPER_MODEL may point to either a model file or a directory containing ggml*.bin.
16
+ const WHISPER_MODEL_NAMES = [
17
+ "ggml-small.en.bin",
18
+ "ggml-small.bin",
19
+ "ggml-base.en.bin",
20
+ "ggml-base.bin",
21
+ "ggml-tiny.en.bin",
22
+ "ggml-tiny.bin",
23
+ ];
15
24
  const WHISPER_MODELS = [
16
25
  process.env.WHISPER_MODEL,
26
+ process.env.WHISPER_MODEL_DIR,
17
27
  "/opt/homebrew/share/whisper-cpp/ggml-small.en.bin",
18
28
  "/opt/homebrew/share/whisper-cpp/ggml-small.bin",
19
29
  "/opt/homebrew/share/whisper-cpp/ggml-base.en.bin",
20
30
  "/opt/homebrew/share/whisper-cpp/ggml-base.bin",
31
+ "/opt/homebrew/share/whisper-cpp",
32
+ "/usr/local/share/whisper-cpp",
21
33
  `${process.env.HOME}/.local/share/whisper-cpp/ggml-small.en.bin`,
22
34
  `${process.env.HOME}/.local/share/whisper-cpp/ggml-base.en.bin`,
35
+ `${process.env.HOME}/.local/share/whisper-cpp`,
36
+ `${process.env.HOME}/Library/Application Support/whisper-cpp`,
23
37
  ].filter(Boolean);
24
38
  const WHISPER_BIN_CANDIDATES = [
25
39
  process.env.WHISPER_BIN,
@@ -35,6 +49,11 @@ const FFMPEG_CANDIDATES = [
35
49
  "/usr/local/bin/ffmpeg",
36
50
  "/usr/bin/ffmpeg",
37
51
  ].filter(Boolean);
52
+ const WHISPER_DOWNLOAD_CANDIDATES = [
53
+ process.env.WHISPER_DOWNLOAD_BIN,
54
+ "/opt/homebrew/bin/whisper-cpp-download-ggml-model",
55
+ "/usr/local/bin/whisper-cpp-download-ggml-model",
56
+ ].filter(Boolean);
38
57
  function findBin(candidates) {
39
58
  for (const p of candidates) {
40
59
  if (existsSync(p))
@@ -44,11 +63,39 @@ function findBin(candidates) {
44
63
  }
45
64
  function findModel() {
46
65
  for (const p of WHISPER_MODELS) {
47
- if (existsSync(p))
66
+ if (!existsSync(p))
67
+ continue;
68
+ try {
69
+ const entries = readdirSync(p, { withFileTypes: true });
70
+ const model = WHISPER_MODEL_NAMES.find((name) => entries.some((entry) => entry.isFile() && entry.name === name));
71
+ if (model)
72
+ return join(p, model);
73
+ const anyModel = entries.find((entry) => entry.isFile() && /^ggml.*\.bin$/i.test(entry.name));
74
+ if (anyModel)
75
+ return join(p, anyModel.name);
76
+ }
77
+ catch {
48
78
  return p;
79
+ }
49
80
  }
50
81
  return null;
51
82
  }
83
+ async function ensureModel() {
84
+ const existing = findModel();
85
+ if (existing)
86
+ return existing;
87
+ const downloader = findBin(WHISPER_DOWNLOAD_CANDIDATES);
88
+ if (!downloader)
89
+ return null;
90
+ const modelDir = process.env.WHISPER_MODEL_DIR || `${process.env.HOME}/.local/share/whisper-cpp`;
91
+ await mkdir(modelDir, { recursive: true });
92
+ await execFileAsync(downloader, ["small.en"], {
93
+ cwd: modelDir,
94
+ timeout: 10 * 60_000,
95
+ maxBuffer: 1024 * 1024,
96
+ });
97
+ return findModel();
98
+ }
52
99
  /**
53
100
  * Download a file from a URL to dest.
54
101
  * Supports file:// (copies local file), and https/http with redirect following.
@@ -95,7 +142,7 @@ export async function transcribeVoice(fileUrl) {
95
142
  const ffmpegBin = findBin(FFMPEG_CANDIDATES);
96
143
  if (!ffmpegBin)
97
144
  throw new Error("ffmpeg not found — install with: brew install ffmpeg");
98
- const model = findModel();
145
+ const model = await ensureModel();
99
146
  if (!model)
100
147
  throw new Error("No whisper model found — run: whisper-cpp-download-ggml-model small.en");
101
148
  const tmp = join(tmpdir(), `cc-discord-voice-${Date.now()}`);
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.48",
4
- "description": "Claude Code Discord bot chat with Claude Code via Discord",
3
+ "version": "0.2.50",
4
+ "description": "Discord bot for routed Claude Code or Codex agents with ATC dispatch",
5
5
  "type": "module",
6
6
  "bin": {
7
- "cc-discord": "./dist/index.js"
7
+ "cc-discord": "dist/index.js"
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsc && chmod +x dist/index.js",
@@ -13,7 +13,8 @@
13
13
  "test": "vitest run",
14
14
  "test:watch": "vitest",
15
15
  "test:coverage": "vitest run --coverage",
16
- "test:integration": "CLAUDE_BIN=./test/fixtures/mock-claude.js vitest run test/integration/"
16
+ "test:integration": "CLAUDE_BIN=./test/fixtures/mock-claude.js vitest run test/integration/",
17
+ "test:integration:codex": "CC_DISCORD_AGENT_DRIVER=codex CODEX_BIN=./test/fixtures/mock-codex.js vitest run test/integration/meta-agent.test.ts"
17
18
  },
18
19
  "files": [
19
20
  "dist/"
@@ -33,7 +34,7 @@
33
34
  },
34
35
  "repository": {
35
36
  "type": "git",
36
- "url": "https://github.com/Gonzih/cc-discord.git"
37
+ "url": "git+https://github.com/Gonzih/cc-discord.git"
37
38
  },
38
39
  "homepage": "https://github.com/Gonzih/cc-discord",
39
40
  "bugs": {
@@ -42,6 +43,8 @@
42
43
  "keywords": [
43
44
  "claude",
44
45
  "claude-code",
46
+ "codex",
47
+ "atc",
45
48
  "discord",
46
49
  "bot",
47
50
  "ai"