@gonzih/cc-discord 0.2.48 → 0.2.49

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
@@ -59,6 +59,16 @@ export declare class CcDiscordBot {
59
59
  private metaAgentTypingTimers;
60
60
  /** Live Discord messages per channel — edited in-place as Claude streams output. */
61
61
  private liveMessages;
62
+ private liveEditQueue;
63
+ private liveEditTimer;
64
+ private nextLiveEditAt;
65
+ private liveEditBackoffMs;
66
+ private liveMessageKey;
67
+ private liveEditFailureDelayMs;
68
+ private editLiveMessageWithPacing;
69
+ private enqueueLiveEdit;
70
+ private scheduleLiveEditDrain;
71
+ private drainOneLiveEdit;
62
72
  /** Start (or reset) the typing indicator for a meta-agent–routed channel. */
63
73
  private startMetaAgentTyping;
64
74
  /** Stop the typing indicator for a meta-agent–routed channel. Called by the notifier on flush. */
@@ -68,19 +78,19 @@ export declare class CcDiscordBot {
68
78
  * If a live message already exists (e.g. from a tool_start), returns it (for reuse).
69
79
  * Otherwise sends `initial` to create the placeholder and stops the typing indicator.
70
80
  */
71
- startOrGetLiveMessage(channelId: string, initial: string): Promise<Message | null>;
81
+ startOrGetLiveMessage(channelId: string, initial: string, slot?: string): Promise<Message | null>;
72
82
  /**
73
83
  * 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).
84
+ * Safe to call on every token/chunk — edits are coalesced behind one global
85
+ * paced queue. The latest `text` always wins for each live message.
76
86
  */
77
- updateLiveMessage(channelId: string, text: string): void;
87
+ updateLiveMessage(channelId: string, text: string, slot?: string): void;
78
88
  /**
79
89
  * Final edit of the live message — applies `text` without cursor, clears tracker.
80
90
  * If no live message exists, falls back to sendWithFileDetection (new message).
81
91
  * Overflow past 2000 chars spawns continuation messages via sendToChannelById.
82
92
  */
83
- finalizeLiveMessage(channelId: string, text: string): Promise<void>;
93
+ finalizeLiveMessage(channelId: string, text: string, slot?: string): Promise<void>;
84
94
  /** Session key: "channelId" or "channelId:threadId" for threads */
85
95
  private sessionKey;
86
96
  /** Get the channel/thread for sending messages */
@@ -128,11 +138,10 @@ export declare class CcDiscordBot {
128
138
  * Namespace is derived from the channel's registered namespace.
129
139
  */
130
140
  private handleLoopEngineCommand;
131
- /**
132
- * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
133
- * Returns the tool result as a string, or null on failure.
134
- */
135
- callCcAgentTool(toolName: string, args?: Record<string, unknown>): Promise<string | null>;
141
+ private atcEnv;
142
+ private integrationStatus;
143
+ private getWikiContent;
144
+ private handleAtcCommand;
136
145
  private runCronTask;
137
146
  /**
138
147
  * Create a new Discord text channel for `namespace`, register it in channelNamespaceMap,
@@ -161,7 +170,7 @@ export declare class CcDiscordBot {
161
170
  */
162
171
  handleUserMessage(channelId: string, text: string): Promise<void>;
163
172
  /**
164
- * Forward a cc-agent job notification into an existing Claude session.
173
+ * Forward an ATC/job notification into an existing Claude session.
165
174
  * Unlike handleUserMessage, this never creates a new session.
166
175
  */
167
176
  forwardNotification(channelId: string, text: string): void;
package/dist/bot.js CHANGED
@@ -8,6 +8,7 @@ import { resolve, basename, join } from "path";
8
8
  import { homedir } from "os";
9
9
  import https from "https";
10
10
  import http from "http";
11
+ import { execFile } from "child_process";
11
12
  import { createCcWire } from "@gonzih/cc-wire";
12
13
  import { ClaudeProcess, extractText } from "./claude.js";
13
14
  import { transcribeVoice, isVoiceAvailable } from "./voice.js";
@@ -18,7 +19,7 @@ import { CronManager } from "./cron.js";
18
19
  import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
19
20
  import { LoopEngine, parseIntervalMs } from "./loop-engine.js";
20
21
  import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
21
- import { createMetaAgentManager } from "./meta-agent-manager.js";
22
+ import { createMetaAgentManager, workspacePath } from "./meta-agent-manager.js";
22
23
  /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
23
24
  function snowflakeToInt(id) {
24
25
  // Discord snowflakes are up to 2^63, beyond Number.MAX_SAFE_INTEGER.
@@ -32,12 +33,132 @@ const PRICING = {
32
33
  cacheReadPerM: 0.30,
33
34
  cacheWritePerM: 3.75,
34
35
  };
36
+ function envPositiveNumber(name, fallback) {
37
+ const raw = process.env[name];
38
+ if (!raw)
39
+ return fallback;
40
+ const parsed = Number(raw);
41
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
42
+ }
43
+ const LIVE_EDIT_MAX_PER_MINUTE = envPositiveNumber("CC_DISCORD_LIVE_EDIT_MAX_PER_MINUTE", 80);
44
+ const LIVE_EDIT_SAFETY_FACTOR = Math.min(0.95, Math.max(0.5, envPositiveNumber("CC_DISCORD_LIVE_EDIT_SAFETY_FACTOR", 0.8)));
45
+ const LIVE_EDIT_MIN_INTERVAL_MS = Math.max(750, Math.ceil(60_000 / Math.max(1, LIVE_EDIT_MAX_PER_MINUTE * LIVE_EDIT_SAFETY_FACTOR)));
46
+ const LIVE_EDIT_INITIAL_BACKOFF_MS = envPositiveNumber("CC_DISCORD_LIVE_EDIT_INITIAL_BACKOFF_MS", 1_500);
47
+ const LIVE_EDIT_MAX_BACKOFF_MS = envPositiveNumber("CC_DISCORD_LIVE_EDIT_MAX_BACKOFF_MS", 60_000);
48
+ function discordRetryAfterMs(err) {
49
+ const record = err;
50
+ const candidates = [
51
+ record.retryAfter,
52
+ record.retry_after,
53
+ record.rawError?.retry_after,
54
+ record.response?.headers?.get?.("retry-after"),
55
+ ];
56
+ for (const candidate of candidates) {
57
+ const value = typeof candidate === "string" ? Number(candidate) : candidate;
58
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
59
+ return value < 1_000 ? Math.ceil(value * 1_000) : Math.ceil(value);
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ function sleepMs(ms) {
65
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
66
+ }
35
67
  function computeCostUsd(usage) {
36
68
  return (usage.inputTokens * PRICING.inputPerM / 1_000_000 +
37
69
  usage.outputTokens * PRICING.outputPerM / 1_000_000 +
38
70
  usage.cacheReadTokens * PRICING.cacheReadPerM / 1_000_000 +
39
71
  usage.cacheWriteTokens * PRICING.cacheWritePerM / 1_000_000);
40
72
  }
73
+ function resolveAtcBin() {
74
+ if (process.env.ATC_BIN)
75
+ return process.env.ATC_BIN;
76
+ const dirs = (process.env.PATH ?? "").split(":").filter(Boolean);
77
+ for (const dir of dirs) {
78
+ const candidate = join(dir, "atc");
79
+ if (existsSync(candidate))
80
+ return candidate;
81
+ }
82
+ const fallbacks = [
83
+ `${homedir()}/.cargo/bin/atc`,
84
+ "/opt/homebrew/bin/atc",
85
+ "/usr/local/bin/atc",
86
+ "/usr/bin/atc",
87
+ ];
88
+ for (const candidate of fallbacks) {
89
+ if (existsSync(candidate))
90
+ return candidate;
91
+ }
92
+ return "atc";
93
+ }
94
+ function resolveGitKbBin() {
95
+ if (process.env.GITKB_BIN)
96
+ return process.env.GITKB_BIN;
97
+ const dirs = (process.env.PATH ?? "").split(":").filter(Boolean);
98
+ for (const dir of dirs) {
99
+ const candidate = join(dir, "git-kb");
100
+ if (existsSync(candidate))
101
+ return candidate;
102
+ }
103
+ return "/opt/homebrew/bin/git-kb";
104
+ }
105
+ function execText(command, args, opts) {
106
+ return new Promise((resolvePromise, reject) => {
107
+ execFile(command, args, {
108
+ cwd: opts.cwd,
109
+ env: opts.env ?? process.env,
110
+ timeout: opts.timeoutMs ?? 20_000,
111
+ maxBuffer: 512 * 1024,
112
+ }, (err, stdout, stderr) => {
113
+ if (err) {
114
+ const detail = stderr.toString().trim() || err.message;
115
+ reject(new Error(detail));
116
+ return;
117
+ }
118
+ resolvePromise(stdout.toString().trim());
119
+ });
120
+ });
121
+ }
122
+ function splitCliArgs(input) {
123
+ const args = [];
124
+ let current = "";
125
+ let quote = null;
126
+ let escaped = false;
127
+ for (const ch of input.trim()) {
128
+ if (escaped) {
129
+ current += ch;
130
+ escaped = false;
131
+ continue;
132
+ }
133
+ if (ch === "\\" && quote !== "'") {
134
+ escaped = true;
135
+ continue;
136
+ }
137
+ if ((ch === "'" || ch === "\"") && !quote) {
138
+ quote = ch;
139
+ continue;
140
+ }
141
+ if (quote === ch) {
142
+ quote = null;
143
+ continue;
144
+ }
145
+ if (/\s/.test(ch) && !quote) {
146
+ if (current) {
147
+ args.push(current);
148
+ current = "";
149
+ }
150
+ continue;
151
+ }
152
+ current += ch;
153
+ }
154
+ if (quote)
155
+ throw new Error("Unclosed quote in ATC args");
156
+ if (escaped)
157
+ current += "\\";
158
+ if (current)
159
+ args.push(current);
160
+ return args;
161
+ }
41
162
  // Debounces streaming chunks. Resets on each chunk. Fires 800ms after last chunk.
42
163
  const FLUSH_DELAY_MS = 800;
43
164
  // Discord typing indicator: re-send every 9s (indicator expires after ~10s)
@@ -233,6 +354,73 @@ export class CcDiscordBot {
233
354
  metaAgentTypingTimers = new Map();
234
355
  /** Live Discord messages per channel — edited in-place as Claude streams output. */
235
356
  liveMessages = new Map();
357
+ liveEditQueue = new Map();
358
+ liveEditTimer = null;
359
+ nextLiveEditAt = 0;
360
+ liveEditBackoffMs = 0;
361
+ liveMessageKey(channelId, slot = "response") {
362
+ return `${channelId}:${slot}`;
363
+ }
364
+ liveEditFailureDelayMs(err) {
365
+ const retryAfter = discordRetryAfterMs(err);
366
+ const backoff = retryAfter ?? (this.liveEditBackoffMs > 0
367
+ ? Math.min(this.liveEditBackoffMs * 2, LIVE_EDIT_MAX_BACKOFF_MS)
368
+ : LIVE_EDIT_INITIAL_BACKOFF_MS);
369
+ const jitter = Math.floor(backoff * 0.1 * Math.random());
370
+ this.liveEditBackoffMs = Math.min(backoff, LIVE_EDIT_MAX_BACKOFF_MS);
371
+ return this.liveEditBackoffMs + jitter;
372
+ }
373
+ async editLiveMessageWithPacing(channelId, msg, text, maxAttempts = 3) {
374
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
375
+ const delay = Math.max(0, this.nextLiveEditAt - Date.now());
376
+ if (delay > 0)
377
+ await sleepMs(delay);
378
+ try {
379
+ await msg.edit(text);
380
+ this.liveEditBackoffMs = 0;
381
+ this.nextLiveEditAt = Date.now() + LIVE_EDIT_MIN_INTERVAL_MS;
382
+ return;
383
+ }
384
+ catch (err) {
385
+ const retryDelay = this.liveEditFailureDelayMs(err);
386
+ this.nextLiveEditAt = Date.now() + retryDelay;
387
+ console.warn(`[bot] live message edit failed (${channelId}); retrying in ${retryDelay}ms:`, err.message);
388
+ if (attempt === maxAttempts)
389
+ throw err;
390
+ }
391
+ }
392
+ }
393
+ enqueueLiveEdit(channelId, msg, text) {
394
+ this.liveEditQueue.set(channelId, { msg, text: text.slice(0, 1990) });
395
+ this.scheduleLiveEditDrain();
396
+ }
397
+ scheduleLiveEditDrain() {
398
+ if (this.liveEditTimer || this.liveEditQueue.size === 0)
399
+ return;
400
+ const delay = Math.max(0, this.nextLiveEditAt - Date.now());
401
+ this.liveEditTimer = setTimeout(() => {
402
+ this.liveEditTimer = null;
403
+ void this.drainOneLiveEdit();
404
+ }, delay);
405
+ }
406
+ async drainOneLiveEdit() {
407
+ const next = this.liveEditQueue.entries().next();
408
+ if (next.done)
409
+ return;
410
+ const [key, entry] = next.value;
411
+ this.liveEditQueue.delete(key);
412
+ try {
413
+ await this.editLiveMessageWithPacing(key, entry.msg, entry.text, 1);
414
+ }
415
+ catch (err) {
416
+ if (this.liveMessages.get(key)?.msg.id === entry.msg.id) {
417
+ this.liveEditQueue.set(key, entry);
418
+ }
419
+ }
420
+ finally {
421
+ this.scheduleLiveEditDrain();
422
+ }
423
+ }
236
424
  /** Start (or reset) the typing indicator for a meta-agent–routed channel. */
237
425
  startMetaAgentTyping(channelId, channel) {
238
426
  this.stopMetaAgentTyping(channelId);
@@ -252,8 +440,9 @@ export class CcDiscordBot {
252
440
  * If a live message already exists (e.g. from a tool_start), returns it (for reuse).
253
441
  * Otherwise sends `initial` to create the placeholder and stops the typing indicator.
254
442
  */
255
- async startOrGetLiveMessage(channelId, initial) {
256
- const existing = this.liveMessages.get(channelId);
443
+ async startOrGetLiveMessage(channelId, initial, slot = "response") {
444
+ const key = this.liveMessageKey(channelId, slot);
445
+ const existing = this.liveMessages.get(key);
257
446
  if (existing)
258
447
  return existing.msg;
259
448
  const channel = await this.getChannel(channelId);
@@ -262,54 +451,37 @@ export class CcDiscordBot {
262
451
  this.stopMetaAgentTyping(channelId);
263
452
  try {
264
453
  const msg = await channel.send(initial || "▋");
265
- this.liveMessages.set(channelId, { msg, lastEditAt: Date.now(), editTimer: null, pendingText: null });
454
+ this.liveMessages.set(key, { msg });
455
+ this.nextLiveEditAt = Math.max(this.nextLiveEditAt, Date.now() + LIVE_EDIT_MIN_INTERVAL_MS);
266
456
  return msg;
267
457
  }
268
458
  catch (err) {
269
- console.warn(`[bot] startOrGetLiveMessage failed (${channelId}):`, err.message);
459
+ console.warn(`[bot] startOrGetLiveMessage failed (${key}):`, err.message);
270
460
  return null;
271
461
  }
272
462
  }
273
463
  /**
274
464
  * 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).
465
+ * Safe to call on every token/chunk — edits are coalesced behind one global
466
+ * paced queue. The latest `text` always wins for each live message.
277
467
  */
278
- updateLiveMessage(channelId, text) {
279
- const entry = this.liveMessages.get(channelId);
468
+ updateLiveMessage(channelId, text, slot = "response") {
469
+ const key = this.liveMessageKey(channelId, slot);
470
+ const entry = this.liveMessages.get(key);
280
471
  if (!entry)
281
472
  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);
473
+ this.enqueueLiveEdit(key, entry.msg, text);
300
474
  }
301
475
  /**
302
476
  * Final edit of the live message — applies `text` without cursor, clears tracker.
303
477
  * If no live message exists, falls back to sendWithFileDetection (new message).
304
478
  * Overflow past 2000 chars spawns continuation messages via sendToChannelById.
305
479
  */
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);
480
+ async finalizeLiveMessage(channelId, text, slot = "response") {
481
+ const key = this.liveMessageKey(channelId, slot);
482
+ const entry = this.liveMessages.get(key);
483
+ this.liveMessages.delete(key);
484
+ this.liveEditQueue.delete(key);
313
485
  if (!entry) {
314
486
  if (text.trim())
315
487
  await this.sendWithFileDetection(channelId, text);
@@ -323,7 +495,7 @@ export class CcDiscordBot {
323
495
  const formatted = formatForDiscord(text);
324
496
  const chunks = splitLongMessage(formatted);
325
497
  try {
326
- await entry.msg.edit(chunks[0]);
498
+ await this.editLiveMessageWithPacing(key, entry.msg, chunks[0]);
327
499
  for (const chunk of chunks.slice(1)) {
328
500
  if (chunk.trim())
329
501
  await this.sendToChannelById(channelId, chunk);
@@ -885,7 +1057,7 @@ export class CcDiscordBot {
885
1057
  const commands = [
886
1058
  new SlashCommandBuilder().setName("reset").setDescription("Reset Claude session for this channel"),
887
1059
  new SlashCommandBuilder().setName("costs").setDescription("Show token usage and cost for this channel"),
888
- new SlashCommandBuilder().setName("mcp_status").setDescription("Check MCP server connection status"),
1060
+ new SlashCommandBuilder().setName("mcp_status").setDescription("Check GitKB and ATC integration status"),
889
1061
  new SlashCommandBuilder()
890
1062
  .setName("crons")
891
1063
  .setDescription("Manage cron jobs")
@@ -902,6 +1074,22 @@ export class CcDiscordBot {
902
1074
  .setName("wiki")
903
1075
  .setDescription("Wiki page info (pass namespace to look up)")
904
1076
  .addStringOption((opt) => opt.setName("namespace").setDescription("Namespace to look up").setRequired(false)),
1077
+ new SlashCommandBuilder()
1078
+ .setName("atc")
1079
+ .setDescription("Inspect or control ATC dispatches")
1080
+ .addSubcommand((sub) => sub.setName("status").setDescription("Show active ATC dispatches"))
1081
+ .addSubcommand((sub) => sub
1082
+ .setName("dryrun")
1083
+ .setDescription("Preview an ATC run command without dispatching")
1084
+ .addStringOption((opt) => opt.setName("args").setDescription("Arguments after `atc`, e.g. run task tasks/foo").setRequired(true)))
1085
+ .addSubcommand((sub) => sub
1086
+ .setName("logs")
1087
+ .setDescription("Show ATC logs for a task slug or dispatch ID")
1088
+ .addStringOption((opt) => opt.setName("id").setDescription("Task slug or dispatch ID").setRequired(true)))
1089
+ .addSubcommand((sub) => sub
1090
+ .setName("health")
1091
+ .setDescription("Run ATC health checks")
1092
+ .addBooleanOption((opt) => opt.setName("auto").setDescription("Auto-dispatch review-fix for NeedsReview records"))),
905
1093
  new SlashCommandBuilder()
906
1094
  .setName("channel")
907
1095
  .setDescription("Create a Discord channel for a GitHub repo meta-agent")
@@ -1022,11 +1210,11 @@ export class CcDiscordBot {
1022
1210
  case "mcp_status": {
1023
1211
  await interaction.deferReply();
1024
1212
  try {
1025
- const result = await this.callCcAgentTool("get_version");
1026
- await interaction.editReply(result ? `MCP connected. Version: ${result}` : "MCP connected (no version info).");
1213
+ const result = await this.integrationStatus();
1214
+ await interaction.editReply(result);
1027
1215
  }
1028
1216
  catch (err) {
1029
- await interaction.editReply(`MCP unavailable: ${err.message}`);
1217
+ await interaction.editReply(`Integration check failed: ${err.message}`);
1030
1218
  }
1031
1219
  break;
1032
1220
  }
@@ -1038,7 +1226,7 @@ export class CcDiscordBot {
1038
1226
  await interaction.deferReply();
1039
1227
  const ns = interaction.options.getString("namespace") ?? this.namespace;
1040
1228
  try {
1041
- const result = await this.callCcAgentTool("get_wiki", { namespace: ns });
1229
+ const result = await this.getWikiContent(ns);
1042
1230
  if (result) {
1043
1231
  const chunks = splitLongMessage(result);
1044
1232
  await interaction.editReply(chunks[0]);
@@ -1055,6 +1243,10 @@ export class CcDiscordBot {
1055
1243
  }
1056
1244
  break;
1057
1245
  }
1246
+ case "atc": {
1247
+ await this.handleAtcCommand(interaction);
1248
+ break;
1249
+ }
1058
1250
  case "channel": {
1059
1251
  const repoUrl = interaction.options.getString("repo", true);
1060
1252
  const urlMatch = repoUrl.match(/^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)/i);
@@ -1318,40 +1510,102 @@ export class CcDiscordBot {
1318
1510
  await interaction.reply("Unknown /loop subcommand.");
1319
1511
  }
1320
1512
  }
1321
- /**
1322
- * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
1323
- * Returns the tool result as a string, or null on failure.
1324
- */
1325
- async callCcAgentTool(toolName, args = {}) {
1326
- return new Promise((resolve) => {
1327
- const prompt = `Use the ${toolName} tool with these arguments: ${JSON.stringify(args)}. Return only the raw result, no extra commentary.`;
1328
- const claude = new ClaudeProcess({
1329
- cwd: this.opts.cwd ?? process.cwd(),
1330
- token: this.opts.claudeToken ?? getCurrentToken(),
1331
- });
1332
- let result = "";
1333
- const timeout = setTimeout(() => {
1334
- claude.kill();
1335
- resolve(null);
1336
- }, 30_000);
1337
- claude.on("message", (msg) => {
1338
- if (msg.type === "result") {
1339
- result = extractText(msg) || result;
1340
- }
1341
- else if (msg.type === "assistant") {
1342
- result += extractText(msg);
1343
- }
1344
- });
1345
- claude.on("exit", () => {
1346
- clearTimeout(timeout);
1347
- resolve(result.trim() || null);
1348
- });
1349
- claude.on("error", () => {
1350
- clearTimeout(timeout);
1351
- resolve(null);
1513
+ atcEnv(ns) {
1514
+ const root = ns ? workspacePath(ns) : (this.opts.cwd ?? process.cwd());
1515
+ return {
1516
+ ...process.env,
1517
+ GITKB_ROOT: process.env.GITKB_ROOT ?? root,
1518
+ DISPATCH_KB_ROOT: process.env.DISPATCH_KB_ROOT ?? root,
1519
+ DISPATCH_META_ROOT: process.env.DISPATCH_META_ROOT ?? root,
1520
+ ATC_NO_PAGER: "1",
1521
+ NO_COLOR: "1",
1522
+ };
1523
+ }
1524
+ async integrationStatus() {
1525
+ const cwd = this.opts.cwd ?? process.cwd();
1526
+ const gitkb = await execText(resolveGitKbBin(), ["--version"], { cwd, timeoutMs: 10_000 });
1527
+ const atc = await execText(resolveAtcBin(), ["--version"], { cwd, env: this.atcEnv(), timeoutMs: 10_000 });
1528
+ const status = await execText(resolveAtcBin(), ["status", "--flat", "--no-pager", "--color", "never"], {
1529
+ cwd,
1530
+ env: this.atcEnv(),
1531
+ timeoutMs: 15_000,
1532
+ }).catch((err) => `ATC status unavailable: ${err.message}`);
1533
+ return [
1534
+ `GitKB: ${gitkb || "available"}`,
1535
+ `ATC: ${atc || "available"}`,
1536
+ "",
1537
+ "ATC status:",
1538
+ status || "No active dispatches.",
1539
+ ].join("\n");
1540
+ }
1541
+ async getWikiContent(ns) {
1542
+ const root = workspacePath(ns);
1543
+ const candidates = [`wiki/${ns}`, `wiki/${ns}.md`, ns];
1544
+ for (const slug of candidates) {
1545
+ const result = await execText(resolveGitKbBin(), ["show", slug], {
1546
+ cwd: root,
1547
+ env: { ...process.env, GITKB_ROOT: root, NO_COLOR: "1" },
1548
+ timeoutMs: 15_000,
1549
+ }).catch(() => "");
1550
+ if (result)
1551
+ return result;
1552
+ }
1553
+ const list = await execText(resolveGitKbBin(), ["list", "--path", "wiki", "--format", "ids"], {
1554
+ cwd: root,
1555
+ env: { ...process.env, GITKB_ROOT: root, NO_COLOR: "1" },
1556
+ timeoutMs: 15_000,
1557
+ }).catch(() => "");
1558
+ return list || null;
1559
+ }
1560
+ async handleAtcCommand(interaction) {
1561
+ await interaction.deferReply();
1562
+ const ns = this.resolveNamespaceForChannel(interaction.channelId);
1563
+ const cwd = workspacePath(ns);
1564
+ const sub = interaction.options.getSubcommand();
1565
+ const args = ["--no-pager", "--color", "never"];
1566
+ if (sub === "status") {
1567
+ args.push("status", "--flat");
1568
+ }
1569
+ else if (sub === "dryrun") {
1570
+ const raw = interaction.options.getString("args", true);
1571
+ const parsed = splitCliArgs(raw);
1572
+ if (parsed[0] === "atc")
1573
+ parsed.shift();
1574
+ if (parsed[0] !== "run") {
1575
+ await interaction.editReply("Dry-run only supports `atc run ...`. Example: `/atc dryrun args:run task tasks/foo`.");
1576
+ return;
1577
+ }
1578
+ args.push(...parsed);
1579
+ if (!args.includes("--dry-run"))
1580
+ args.push("--dry-run");
1581
+ }
1582
+ else if (sub === "logs") {
1583
+ args.push("logs", interaction.options.getString("id", true));
1584
+ }
1585
+ else if (sub === "health") {
1586
+ args.push("health");
1587
+ if (interaction.options.getBoolean("auto") ?? false)
1588
+ args.push("--auto");
1589
+ }
1590
+ else {
1591
+ await interaction.editReply("Unknown /atc subcommand.");
1592
+ return;
1593
+ }
1594
+ try {
1595
+ const output = await execText(resolveAtcBin(), args, {
1596
+ cwd,
1597
+ env: this.atcEnv(ns),
1598
+ timeoutMs: sub === "logs" ? 20_000 : 30_000,
1352
1599
  });
1353
- claude.sendPrompt(prompt);
1354
- });
1600
+ const chunks = splitLongMessage(output || "No ATC output.");
1601
+ await interaction.editReply(chunks[0]);
1602
+ for (const chunk of chunks.slice(1)) {
1603
+ await interaction.followUp(chunk);
1604
+ }
1605
+ }
1606
+ catch (err) {
1607
+ await interaction.editReply(`ATC command failed: ${err.message}`);
1608
+ }
1355
1609
  }
1356
1610
  runCronTask(channelId, prompt, done) {
1357
1611
  const getChannel = this.getChannel.bind(this);
@@ -1508,7 +1762,7 @@ export class CcDiscordBot {
1508
1762
  }
1509
1763
  }
1510
1764
  /**
1511
- * Forward a cc-agent job notification into an existing Claude session.
1765
+ * Forward an ATC/job notification into an existing Claude session.
1512
1766
  * Unlike handleUserMessage, this never creates a new session.
1513
1767
  */
1514
1768
  forwardNotification(channelId, text) {
package/dist/index.d.ts CHANGED
@@ -1,22 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * cc-discord — Claude Code Discord bot
3
+ * cc-discord — Discord bot for Claude Code or Codex routed agents
4
4
  *
5
5
  * Usage:
6
6
  * npx @gonzih/cc-discord
7
7
  *
8
8
  * Required env:
9
9
  * DISCORD_BOT_TOKEN — from Discord Developer Portal
10
- * CLAUDE_CODE_OAUTH_TOKEN — your Claude Code OAuth token (or ANTHROPIC_API_KEY)
10
+ * CLAUDE_CODE_OAUTH_TOKEN — Claude Code OAuth token (or ANTHROPIC_API_KEY) when using Claude
11
+ * CODEX_API_KEY — optional Codex API key when using Codex automation
11
12
  *
12
13
  * Optional env:
13
14
  * DISCORD_GUILD_IDS — comma-separated Discord guild/server IDs (for instant slash command registration)
14
15
  * DISCORD_ALLOWED_USER_IDS — comma-separated Discord user IDs to whitelist (leave empty to allow all)
15
16
  * DISCORD_NOTIFY_CHANNEL_ID — Discord channel ID for job notifications
16
- * CC_AGENT_NAMESPACE — primary namespace (default: money-brain)
17
+ * CC_DISCORD_NAMESPACE — primary namespace (default: money-brain)
17
18
  * REDIS_URL — Redis connection URL (default: redis://localhost:6379)
18
- * CWD — working directory for Claude Code (default: process.cwd())
19
+ * CWD — working directory for local sessions (default: process.cwd())
19
20
  * DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
20
- * CC_DISCORD_MCP_JSON JSON template for .mcp.json injection into workspaces
21
+ * CC_DISCORD_AGENT_DRIVER claude (default) or codex for routed meta-agents
22
+ * ATC_BIN — optional, override ATC binary path
23
+ * ATC_ROOT — optional, ATC data directory
24
+ * CC_DISCORD_MCP_JSON — JSON template for .mcp.json injection into workspaces
21
25
  */
22
26
  export {};