@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 +20 -11
- package/dist/bot.js +328 -74
- package/dist/index.d.ts +9 -5
- package/dist/index.js +17 -10
- package/dist/meta-agent-manager.d.ts +2 -2
- package/dist/meta-agent-manager.js +565 -79
- package/dist/notifier.d.ts +2 -2
- package/dist/notifier.js +80 -55
- package/dist/tokens.d.ts +1 -1
- package/dist/tokens.js +1 -1
- package/package.json +8 -5
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
|
|
75
|
-
* The latest `text` always wins
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 (${
|
|
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
|
|
276
|
-
* The latest `text` always wins
|
|
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
|
|
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.
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
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
|
|
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.
|
|
1026
|
-
await interaction.editReply(result
|
|
1213
|
+
const result = await this.integrationStatus();
|
|
1214
|
+
await interaction.editReply(result);
|
|
1027
1215
|
}
|
|
1028
1216
|
catch (err) {
|
|
1029
|
-
await interaction.editReply(`
|
|
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.
|
|
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
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 —
|
|
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
|
-
*
|
|
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
|
|
19
|
+
* CWD — working directory for local sessions (default: process.cwd())
|
|
19
20
|
* DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
|
|
20
|
-
*
|
|
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 {};
|