@gonzih/cc-discord 0.1.16 → 0.2.0
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 +11 -1
- package/dist/bot.js +71 -47
- package/dist/index.d.ts +2 -1
- package/dist/index.js +51 -2
- package/dist/meta-agent-manager.d.ts +61 -0
- package/dist/meta-agent-manager.js +296 -0
- package/dist/notifier.d.ts +9 -8
- package/dist/notifier.js +40 -36
- package/dist/router.d.ts +6 -25
- package/dist/router.js +7 -130
- package/package.json +2 -2
package/dist/bot.d.ts
CHANGED
|
@@ -29,9 +29,11 @@ export declare class CcDiscordBot {
|
|
|
29
29
|
private costs;
|
|
30
30
|
private opts;
|
|
31
31
|
private redis?;
|
|
32
|
+
private wire?;
|
|
32
33
|
private namespace;
|
|
33
34
|
private lastActiveChannelId?;
|
|
34
35
|
private cron;
|
|
36
|
+
private metaAgentManager;
|
|
35
37
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
36
38
|
private mcpSession?;
|
|
37
39
|
constructor(opts: DiscordBotOptions);
|
|
@@ -41,7 +43,7 @@ export declare class CcDiscordBot {
|
|
|
41
43
|
private channelNamespaceMap;
|
|
42
44
|
private storeSnowflake;
|
|
43
45
|
reverseSnowflakeLookup(n: number): string | undefined;
|
|
44
|
-
/** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
|
|
46
|
+
/** Persist a channelId → {namespace, repoUrl} mapping to Redis via wire.discord. */
|
|
45
47
|
private persistChannelMapping;
|
|
46
48
|
/**
|
|
47
49
|
* Load persisted channel→namespace mappings from Redis and repopulate
|
|
@@ -104,5 +106,13 @@ export declare class CcDiscordBot {
|
|
|
104
106
|
* Unlike handleUserMessage, this never creates a new session.
|
|
105
107
|
*/
|
|
106
108
|
forwardNotification(channelId: string, text: string): void;
|
|
109
|
+
/** Resolve the current claude token from wire master or env fallbacks. */
|
|
110
|
+
resolveToken(): Promise<string>;
|
|
111
|
+
/**
|
|
112
|
+
* Start the meta-agent polling loop.
|
|
113
|
+
* Called from index.ts after startup migrations complete.
|
|
114
|
+
* Passes a live getter for the set of registered namespaces.
|
|
115
|
+
*/
|
|
116
|
+
startMetaAgentPolling(): void;
|
|
107
117
|
stop(): void;
|
|
108
118
|
}
|
package/dist/bot.js
CHANGED
|
@@ -7,17 +7,15 @@ import { existsSync, createWriteStream, mkdirSync } from "fs";
|
|
|
7
7
|
import { resolve, basename, join } from "path";
|
|
8
8
|
import https from "https";
|
|
9
9
|
import http from "http";
|
|
10
|
+
import { createCcWire } from "@gonzih/cc-wire";
|
|
10
11
|
import { ClaudeProcess, extractText } from "./claude.js";
|
|
11
12
|
import { transcribeVoice, isVoiceAvailable } from "./voice.js";
|
|
12
13
|
import { formatForDiscord, splitLongMessage, stripAnsi } from "./formatter.js";
|
|
13
14
|
import { getCurrentToken } from "./tokens.js";
|
|
14
15
|
import { writeChatLog } from "./notifier.js";
|
|
15
16
|
import { CronManager } from "./cron.js";
|
|
16
|
-
import { parseChannelCreateIntent,
|
|
17
|
-
|
|
18
|
-
function discordChannelKey(channelId) {
|
|
19
|
-
return `cca:discord:channel:${channelId}`;
|
|
20
|
-
}
|
|
17
|
+
import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
|
|
18
|
+
import { createMetaAgentManager } from "./meta-agent-manager.js";
|
|
21
19
|
/** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
|
|
22
20
|
function snowflakeToInt(id) {
|
|
23
21
|
// Discord snowflakes are up to 2^63, beyond Number.MAX_SAFE_INTEGER.
|
|
@@ -117,15 +115,21 @@ export class CcDiscordBot {
|
|
|
117
115
|
costs = new Map();
|
|
118
116
|
opts;
|
|
119
117
|
redis;
|
|
118
|
+
wire;
|
|
120
119
|
namespace;
|
|
121
120
|
lastActiveChannelId;
|
|
122
121
|
cron;
|
|
122
|
+
metaAgentManager;
|
|
123
123
|
/** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
|
|
124
124
|
mcpSession;
|
|
125
125
|
constructor(opts) {
|
|
126
126
|
this.opts = opts;
|
|
127
127
|
this.redis = opts.redis;
|
|
128
128
|
this.namespace = opts.namespace ?? "default";
|
|
129
|
+
if (opts.redis) {
|
|
130
|
+
this.wire = createCcWire(opts.redis);
|
|
131
|
+
}
|
|
132
|
+
this.metaAgentManager = createMetaAgentManager();
|
|
129
133
|
this.client = new Client({
|
|
130
134
|
intents: [
|
|
131
135
|
GatewayIntentBits.Guilds,
|
|
@@ -183,13 +187,11 @@ export class CcDiscordBot {
|
|
|
183
187
|
reverseSnowflakeLookup(n) {
|
|
184
188
|
return this.snowflakeMap.get(n);
|
|
185
189
|
}
|
|
186
|
-
/** Persist a channelId → {namespace, repoUrl} mapping to Redis. */
|
|
190
|
+
/** Persist a channelId → {namespace, repoUrl} mapping to Redis via wire.discord. */
|
|
187
191
|
persistChannelMapping(channelId, namespace, repoUrl) {
|
|
188
|
-
if (!this.
|
|
192
|
+
if (!this.wire)
|
|
189
193
|
return;
|
|
190
|
-
|
|
191
|
-
const value = JSON.stringify({ namespace, repoUrl });
|
|
192
|
-
this.redis.set(key, value).catch((err) => {
|
|
194
|
+
this.wire.discord.registerChannel(channelId, namespace, repoUrl).catch((err) => {
|
|
193
195
|
console.warn(`[bot] persistChannelMapping failed for ${channelId}:`, err.message);
|
|
194
196
|
});
|
|
195
197
|
}
|
|
@@ -198,31 +200,21 @@ export class CcDiscordBot {
|
|
|
198
200
|
* channelNamespaceMap + routedChannelIds. Call once on startup after the notifier is ready.
|
|
199
201
|
*/
|
|
200
202
|
async loadChannelMappings() {
|
|
201
|
-
if (!this.
|
|
203
|
+
if (!this.wire)
|
|
202
204
|
return;
|
|
203
|
-
let
|
|
205
|
+
let channels;
|
|
204
206
|
try {
|
|
205
|
-
|
|
207
|
+
channels = await this.wire.discord.listChannels();
|
|
206
208
|
}
|
|
207
209
|
catch (err) {
|
|
208
|
-
console.warn("[bot] loadChannelMappings
|
|
210
|
+
console.warn("[bot] loadChannelMappings failed:", err.message);
|
|
209
211
|
return;
|
|
210
212
|
}
|
|
211
|
-
for (const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const { namespace, repoUrl } = JSON.parse(raw);
|
|
217
|
-
const channelId = key.slice("cca:discord:channel:".length);
|
|
218
|
-
if (!this.channelNamespaceMap.has(channelId)) {
|
|
219
|
-
this.channelNamespaceMap.set(channelId, { namespace, repoUrl });
|
|
220
|
-
this.opts.registerRoutedChannelId?.(namespace, channelId);
|
|
221
|
-
console.log(`[bot] restored channel mapping: ${channelId} → ${namespace}`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch (err) {
|
|
225
|
-
console.warn(`[bot] loadChannelMappings: failed to parse ${key}:`, err.message);
|
|
213
|
+
for (const { channelId, namespace, repoUrl } of channels) {
|
|
214
|
+
if (!this.channelNamespaceMap.has(channelId)) {
|
|
215
|
+
this.channelNamespaceMap.set(channelId, { namespace, repoUrl });
|
|
216
|
+
this.opts.registerRoutedChannelId?.(namespace, channelId);
|
|
217
|
+
console.log(`[bot] restored channel mapping: ${channelId} → ${namespace}`);
|
|
226
218
|
}
|
|
227
219
|
}
|
|
228
220
|
}
|
|
@@ -421,10 +413,11 @@ export class CcDiscordBot {
|
|
|
421
413
|
const caption = msg.content.trim().replace(/<@!?\d+>/g, "").trim();
|
|
422
414
|
const fullText = caption ? `${caption}\n\n${transcript}` : transcript;
|
|
423
415
|
const voiceUsername = msg.member?.displayName ?? msg.author.username;
|
|
424
|
-
const prompt = stampPrompt(fullText, voiceUsername, msg.createdAt);
|
|
425
416
|
// Meta-agent routing
|
|
426
417
|
const mappedNs = this.channelNamespaceMap.get(channelId);
|
|
427
418
|
if (mappedNs && this.redis) {
|
|
419
|
+
const labeledText = `[voice note — transcription may contain typos]: ${fullText}`;
|
|
420
|
+
const prompt = stampPrompt(labeledText, voiceUsername, msg.createdAt);
|
|
428
421
|
this.writeChatMessage("user", "discord", fullText, channelId, mappedNs.namespace);
|
|
429
422
|
this.opts.registerRoutedChannelId?.(mappedNs.namespace, channelId);
|
|
430
423
|
this.persistChannelMapping(channelId, mappedNs.namespace, mappedNs.repoUrl);
|
|
@@ -439,7 +432,7 @@ export class CcDiscordBot {
|
|
|
439
432
|
}
|
|
440
433
|
const session = this.getOrCreateSession(channelId, channel);
|
|
441
434
|
session.currentPrompt = fullText;
|
|
442
|
-
session.claude.sendPrompt(
|
|
435
|
+
session.claude.sendPrompt(stampPrompt(fullText, voiceUsername, msg.createdAt));
|
|
443
436
|
this.startTyping(channelId, channel, session);
|
|
444
437
|
this.writeChatMessage("user", "discord", fullText, channelId);
|
|
445
438
|
}
|
|
@@ -831,14 +824,16 @@ export class CcDiscordBot {
|
|
|
831
824
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
832
825
|
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
833
826
|
await interaction.editReply(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`);
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
827
|
+
// Clone workspace and inject MCP config in the background
|
|
828
|
+
this.metaAgentManager.ensureWorkspace(namespace, repoUrl)
|
|
829
|
+
.then(async () => {
|
|
830
|
+
const token = await this.resolveToken();
|
|
831
|
+
this.metaAgentManager.injectMcp(namespace, token);
|
|
832
|
+
})
|
|
833
|
+
.catch((err) => {
|
|
834
|
+
console.error(`[bot] /channel workspace setup(${namespace}) failed:`, err.message);
|
|
835
|
+
this.sendToChannelById(newChannel.id, `Warning: workspace setup failed — ${err.message}`).catch(() => { });
|
|
836
|
+
});
|
|
842
837
|
}
|
|
843
838
|
catch (err) {
|
|
844
839
|
await interaction.editReply(`Failed to create channel: ${err.message}`);
|
|
@@ -975,14 +970,16 @@ export class CcDiscordBot {
|
|
|
975
970
|
this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
|
|
976
971
|
this.persistChannelMapping(newChannel.id, namespace, repoUrl);
|
|
977
972
|
await channel.send(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`).catch(() => { });
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
973
|
+
// Clone workspace and inject MCP config in the background after acknowledging the user
|
|
974
|
+
this.metaAgentManager.ensureWorkspace(namespace, repoUrl)
|
|
975
|
+
.then(async () => {
|
|
976
|
+
const token = await this.resolveToken();
|
|
977
|
+
this.metaAgentManager.injectMcp(namespace, token);
|
|
978
|
+
})
|
|
979
|
+
.catch((err) => {
|
|
980
|
+
console.error(`[bot] workspace setup(${namespace}) failed:`, err.message);
|
|
981
|
+
this.sendToChannelById(newChannel.id, `Warning: workspace setup failed — ${err.message}`).catch(() => { });
|
|
982
|
+
});
|
|
986
983
|
}
|
|
987
984
|
/** Write a message to the Redis chat log. Fire-and-forget.
|
|
988
985
|
* Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
|
|
@@ -1042,6 +1039,32 @@ export class CcDiscordBot {
|
|
|
1042
1039
|
console.error(`[forwardNotification:${channelId}] failed:`, err.message);
|
|
1043
1040
|
}
|
|
1044
1041
|
}
|
|
1042
|
+
/** Resolve the current claude token from wire master or env fallbacks. */
|
|
1043
|
+
async resolveToken() {
|
|
1044
|
+
if (this.wire) {
|
|
1045
|
+
try {
|
|
1046
|
+
return await this.wire.token.getMaster();
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
// master token not set — fall through to env vars
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return (this.opts.claudeToken ??
|
|
1053
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN ??
|
|
1054
|
+
process.env.CLAUDE_CODE_TOKEN ??
|
|
1055
|
+
process.env.ANTHROPIC_API_KEY ??
|
|
1056
|
+
"");
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Start the meta-agent polling loop.
|
|
1060
|
+
* Called from index.ts after startup migrations complete.
|
|
1061
|
+
* Passes a live getter for the set of registered namespaces.
|
|
1062
|
+
*/
|
|
1063
|
+
startMetaAgentPolling() {
|
|
1064
|
+
if (!this.wire)
|
|
1065
|
+
return;
|
|
1066
|
+
this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()).map((v) => v.namespace));
|
|
1067
|
+
}
|
|
1045
1068
|
stop() {
|
|
1046
1069
|
for (const [key, session] of this.sessions) {
|
|
1047
1070
|
if (session.flushTimer)
|
|
@@ -1054,6 +1077,7 @@ export class CcDiscordBot {
|
|
|
1054
1077
|
for (const [channelId] of this.metaAgentTypingTimers) {
|
|
1055
1078
|
this.stopMetaAgentTyping(channelId);
|
|
1056
1079
|
}
|
|
1080
|
+
this.metaAgentManager.stop();
|
|
1057
1081
|
void this.client.destroy();
|
|
1058
1082
|
}
|
|
1059
1083
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
* DISCORD_GUILD_IDS — comma-separated Discord guild/server IDs (for instant slash command registration)
|
|
14
14
|
* DISCORD_ALLOWED_USER_IDS — comma-separated Discord user IDs to whitelist (leave empty to allow all)
|
|
15
15
|
* DISCORD_NOTIFY_CHANNEL_ID — Discord channel ID for job notifications
|
|
16
|
-
* CC_AGENT_NAMESPACE —
|
|
16
|
+
* CC_AGENT_NAMESPACE — primary namespace (default: money-brain)
|
|
17
17
|
* REDIS_URL — Redis connection URL (default: redis://localhost:6379)
|
|
18
18
|
* CWD — working directory for Claude Code (default: process.cwd())
|
|
19
19
|
* DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
|
|
20
|
+
* CC_DISCORD_MCP_JSON — JSON template for .mcp.json injection into workspaces
|
|
20
21
|
*/
|
|
21
22
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -13,16 +13,19 @@
|
|
|
13
13
|
* DISCORD_GUILD_IDS — comma-separated Discord guild/server IDs (for instant slash command registration)
|
|
14
14
|
* DISCORD_ALLOWED_USER_IDS — comma-separated Discord user IDs to whitelist (leave empty to allow all)
|
|
15
15
|
* DISCORD_NOTIFY_CHANNEL_ID — Discord channel ID for job notifications
|
|
16
|
-
* CC_AGENT_NAMESPACE —
|
|
16
|
+
* CC_AGENT_NAMESPACE — primary namespace (default: money-brain)
|
|
17
17
|
* REDIS_URL — Redis connection URL (default: redis://localhost:6379)
|
|
18
18
|
* CWD — working directory for Claude Code (default: process.cwd())
|
|
19
19
|
* DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
|
|
20
|
+
* CC_DISCORD_MCP_JSON — JSON template for .mcp.json injection into workspaces
|
|
20
21
|
*/
|
|
21
22
|
import { createRequire } from "node:module";
|
|
22
23
|
import { Redis } from "ioredis";
|
|
24
|
+
import { createCcWire } from "@gonzih/cc-wire";
|
|
23
25
|
import { CcDiscordBot } from "./bot.js";
|
|
24
26
|
import { startNotifier } from "./notifier.js";
|
|
25
27
|
import { loadTokens } from "./tokens.js";
|
|
28
|
+
import { migrateMetaInputKeys } from "./meta-agent-manager.js";
|
|
26
29
|
const require = createRequire(import.meta.url);
|
|
27
30
|
const { version } = require("../package.json");
|
|
28
31
|
function required(name) {
|
|
@@ -72,13 +75,59 @@ const sharedRedis = new Redis(redisUrl);
|
|
|
72
75
|
sharedRedis.on("error", (err) => {
|
|
73
76
|
console.warn("[redis] connection error:", err.message);
|
|
74
77
|
});
|
|
78
|
+
// cc-wire factory
|
|
79
|
+
const wire = createCcWire(sharedRedis);
|
|
75
80
|
sharedRedis.once("ready", () => {
|
|
76
|
-
// Announce
|
|
81
|
+
// Announce version
|
|
77
82
|
sharedRedis.set(`cca:meta:cc-discord:version`, version).catch((err) => {
|
|
78
83
|
console.warn("[redis] failed to write version:", err.message);
|
|
79
84
|
});
|
|
80
85
|
console.log(`[cc-discord] version:reported ${version}`);
|
|
86
|
+
// Store master token so MetaAgentManager can retrieve it
|
|
87
|
+
wire.token.setMaster(claudeToken).catch((err) => {
|
|
88
|
+
console.warn("[cc-discord] failed to set master token:", err.message);
|
|
89
|
+
});
|
|
90
|
+
// Run startup migrations (async, best-effort)
|
|
91
|
+
void runStartupMigrations();
|
|
81
92
|
});
|
|
93
|
+
/**
|
|
94
|
+
* Migrate old Redis data formats to the v0.2.0 layout.
|
|
95
|
+
* Runs once per startup.
|
|
96
|
+
*/
|
|
97
|
+
async function runStartupMigrations() {
|
|
98
|
+
// 1. Migrate old STRING channel keys to new HSET format
|
|
99
|
+
let stringKeys;
|
|
100
|
+
try {
|
|
101
|
+
stringKeys = await sharedRedis.keys("cca:discord:channel:*");
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.warn("[cc-discord] channel key scan failed:", err.message);
|
|
105
|
+
stringKeys = [];
|
|
106
|
+
}
|
|
107
|
+
for (const key of stringKeys) {
|
|
108
|
+
try {
|
|
109
|
+
const type = await sharedRedis.type(key);
|
|
110
|
+
if (type !== "string")
|
|
111
|
+
continue; // already migrated or different type
|
|
112
|
+
const raw = await sharedRedis.get(key);
|
|
113
|
+
if (!raw)
|
|
114
|
+
continue;
|
|
115
|
+
const { namespace: ns, repoUrl } = JSON.parse(raw);
|
|
116
|
+
const channelId = key.slice("cca:discord:channel:".length);
|
|
117
|
+
await wire.discord.registerChannel(channelId, ns, repoUrl);
|
|
118
|
+
await sharedRedis.del(key);
|
|
119
|
+
console.log(`[cc-discord] migrated channel key ${channelId} → HSET`);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
console.warn(`[cc-discord] channel key migration failed for ${key}:`, err.message);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 2. Migrate old meta input keys (cca:meta:{ns}:input → cca:discord:meta:{ns}:input)
|
|
126
|
+
await migrateMetaInputKeys(sharedRedis);
|
|
127
|
+
console.log("[cc-discord] startup migrations complete");
|
|
128
|
+
// 3. Start meta-agent polling now that data is migrated
|
|
129
|
+
bot.startMetaAgentPolling();
|
|
130
|
+
}
|
|
82
131
|
// Mutable placeholder closures — filled in once `bot` is created below
|
|
83
132
|
let getLastActiveChannelIdFn = () => undefined;
|
|
84
133
|
let handleUserMessageFn;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetaAgentManager — cc-discord owns Claude session lifecycle for routed namespaces.
|
|
3
|
+
*
|
|
4
|
+
* Flow per namespace:
|
|
5
|
+
* 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
|
|
6
|
+
* 2. injectMcp: write .mcp.json so the claude subprocess has MCP tool access
|
|
7
|
+
* 3. pollQueues (3s interval): wire.discord.dequeue(ns) → spawnSession(ns, content)
|
|
8
|
+
* 4. spawnSession: claude --continue -p "{message}" pipes stdout → wire.discord.publishOutgoing
|
|
9
|
+
*/
|
|
10
|
+
import type { createCcWire } from "@gonzih/cc-wire";
|
|
11
|
+
type Wire = ReturnType<typeof createCcWire>;
|
|
12
|
+
/**
|
|
13
|
+
* Returns the path to the workspace for the given namespace.
|
|
14
|
+
* Creates the parent root directory if needed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function workspacePath(ns: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Clone the repo to ~/cc-discord-workspace/{ns} if not already present.
|
|
19
|
+
* No-op if the directory already exists.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ensureWorkspace(ns: string, repoUrl: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Write .mcp.json to the workspace so the claude session has MCP tool access.
|
|
24
|
+
*
|
|
25
|
+
* Template priority:
|
|
26
|
+
* 1. CC_DISCORD_MCP_JSON env var (full JSON string) — operator-supplied override
|
|
27
|
+
* 2. Built-in template: cc-agent MCP server with namespace-scoped env
|
|
28
|
+
*
|
|
29
|
+
* Variables substituted in the template: {namespace}, {workspacePath}, {token},
|
|
30
|
+
* {npmCache}, {trustedOwners}, {path}.
|
|
31
|
+
*/
|
|
32
|
+
export declare function injectMcp(ns: string, wsPath: string, token: string): void;
|
|
33
|
+
/**
|
|
34
|
+
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
35
|
+
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
36
|
+
* Returns a Promise that resolves when the process exits.
|
|
37
|
+
*/
|
|
38
|
+
export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
|
|
39
|
+
export interface MetaAgentManager {
|
|
40
|
+
ensureWorkspace: (ns: string, repoUrl: string) => Promise<void>;
|
|
41
|
+
injectMcp: (ns: string, token: string) => void;
|
|
42
|
+
startPolling: (wire: Wire, getNamespaces: () => string[]) => void;
|
|
43
|
+
stop: () => void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a MetaAgentManager that polls input queues and spawns sessions.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createMetaAgentManager(): MetaAgentManager;
|
|
49
|
+
/**
|
|
50
|
+
* Migrate the old cc-agent meta input key to the new cc-discord key.
|
|
51
|
+
* Old: cca:meta:{ns}:input → New: cca:discord:meta:{ns}:input
|
|
52
|
+
*
|
|
53
|
+
* Called once on startup.
|
|
54
|
+
*/
|
|
55
|
+
export declare function migrateMetaInputKeys(redis: {
|
|
56
|
+
keys: (p: string) => Promise<string[]>;
|
|
57
|
+
lrange: (k: string, s: number, e: number) => Promise<string[]>;
|
|
58
|
+
rpush: (k: string, ...v: string[]) => Promise<number>;
|
|
59
|
+
del: (k: string) => Promise<number>;
|
|
60
|
+
}): Promise<void>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MetaAgentManager — cc-discord owns Claude session lifecycle for routed namespaces.
|
|
3
|
+
*
|
|
4
|
+
* Flow per namespace:
|
|
5
|
+
* 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
|
|
6
|
+
* 2. injectMcp: write .mcp.json so the claude subprocess has MCP tool access
|
|
7
|
+
* 3. pollQueues (3s interval): wire.discord.dequeue(ns) → spawnSession(ns, content)
|
|
8
|
+
* 4. spawnSession: claude --continue -p "{message}" pipes stdout → wire.discord.publishOutgoing
|
|
9
|
+
*/
|
|
10
|
+
import { spawn, execSync } from "child_process";
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { CC_DISCORD_WORKSPACE_ROOT, TIMING, discordMetaInputKey, } from "@gonzih/cc-wire";
|
|
15
|
+
const WORKSPACE_ROOT = join(homedir(), CC_DISCORD_WORKSPACE_ROOT);
|
|
16
|
+
/**
|
|
17
|
+
* Returns the path to the workspace for the given namespace.
|
|
18
|
+
* Creates the parent root directory if needed.
|
|
19
|
+
*/
|
|
20
|
+
export function workspacePath(ns) {
|
|
21
|
+
return join(WORKSPACE_ROOT, ns);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Clone the repo to ~/cc-discord-workspace/{ns} if not already present.
|
|
25
|
+
* No-op if the directory already exists.
|
|
26
|
+
*/
|
|
27
|
+
export async function ensureWorkspace(ns, repoUrl) {
|
|
28
|
+
const wsPath = workspacePath(ns);
|
|
29
|
+
if (existsSync(wsPath)) {
|
|
30
|
+
console.log(`[meta-agent-manager] workspace exists: ${wsPath}`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
mkdirSync(WORKSPACE_ROOT, { recursive: true });
|
|
34
|
+
console.log(`[meta-agent-manager] cloning ${repoUrl} → ${wsPath}`);
|
|
35
|
+
execSync(`git clone ${repoUrl} ${wsPath}`, { stdio: "pipe" });
|
|
36
|
+
console.log(`[meta-agent-manager] clone complete for namespace=${ns}`);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Write .mcp.json to the workspace so the claude session has MCP tool access.
|
|
40
|
+
*
|
|
41
|
+
* Template priority:
|
|
42
|
+
* 1. CC_DISCORD_MCP_JSON env var (full JSON string) — operator-supplied override
|
|
43
|
+
* 2. Built-in template: cc-agent MCP server with namespace-scoped env
|
|
44
|
+
*
|
|
45
|
+
* Variables substituted in the template: {namespace}, {workspacePath}, {token},
|
|
46
|
+
* {npmCache}, {trustedOwners}, {path}.
|
|
47
|
+
*/
|
|
48
|
+
export function injectMcp(ns, wsPath, token) {
|
|
49
|
+
const mcpPath = join(wsPath, ".mcp.json");
|
|
50
|
+
if (process.env.CC_DISCORD_MCP_JSON) {
|
|
51
|
+
const rendered = process.env.CC_DISCORD_MCP_JSON
|
|
52
|
+
.replace(/\{namespace\}/g, ns)
|
|
53
|
+
.replace(/\{workspacePath\}/g, wsPath)
|
|
54
|
+
.replace(/\{token\}/g, token);
|
|
55
|
+
writeFileSync(mcpPath, rendered, "utf8");
|
|
56
|
+
console.log(`[meta-agent-manager] injected MCP config (from CC_DISCORD_MCP_JSON) for ${ns}`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
|
|
60
|
+
const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "";
|
|
61
|
+
const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
62
|
+
const config = {
|
|
63
|
+
mcpServers: {
|
|
64
|
+
"cc-agent": {
|
|
65
|
+
command: "/opt/homebrew/bin/npx",
|
|
66
|
+
args: ["-y", "--prefer-online", "@gonzih/cc-agent"],
|
|
67
|
+
env: {
|
|
68
|
+
CC_AGENT_NAMESPACE: ns,
|
|
69
|
+
CWD: wsPath,
|
|
70
|
+
CLAUDE_CODE_OAUTH_TOKEN: token,
|
|
71
|
+
CLAUDE_TOKENS: token,
|
|
72
|
+
CC_AGENT_TRUSTED_OWNERS: trustedOwners,
|
|
73
|
+
PATH: systemPath,
|
|
74
|
+
npm_config_cache: npmCache,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2), "utf8");
|
|
80
|
+
console.log(`[meta-agent-manager] injected MCP config for namespace=${ns}`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve claude binary — same logic as claude.ts resolveClaude.
|
|
84
|
+
*/
|
|
85
|
+
function resolveClaude() {
|
|
86
|
+
const dirs = (process.env.PATH ?? "").split(":");
|
|
87
|
+
for (const dir of dirs) {
|
|
88
|
+
const c = `${dir}/claude`;
|
|
89
|
+
if (existsSync(c))
|
|
90
|
+
return c;
|
|
91
|
+
}
|
|
92
|
+
const fallbacks = [
|
|
93
|
+
`${homedir()}/.npm-global/bin/claude`,
|
|
94
|
+
"/opt/homebrew/bin/claude",
|
|
95
|
+
"/usr/local/bin/claude",
|
|
96
|
+
"/usr/bin/claude",
|
|
97
|
+
];
|
|
98
|
+
for (const p of fallbacks) {
|
|
99
|
+
if (existsSync(p))
|
|
100
|
+
return p;
|
|
101
|
+
}
|
|
102
|
+
return "claude";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
|
|
106
|
+
* namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
|
|
107
|
+
* Returns a Promise that resolves when the process exits.
|
|
108
|
+
*/
|
|
109
|
+
export function spawnSession(ns, message, token, wire) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const wsPath = workspacePath(ns);
|
|
112
|
+
const claudeBin = resolveClaude();
|
|
113
|
+
const env = { ...process.env };
|
|
114
|
+
if (token.startsWith("sk-ant-api")) {
|
|
115
|
+
env.ANTHROPIC_API_KEY = token;
|
|
116
|
+
delete env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = token;
|
|
120
|
+
delete env.ANTHROPIC_API_KEY;
|
|
121
|
+
}
|
|
122
|
+
const proc = spawn(claudeBin, [
|
|
123
|
+
"--continue",
|
|
124
|
+
"-p", message,
|
|
125
|
+
"--output-format", "text",
|
|
126
|
+
"--dangerously-skip-permissions",
|
|
127
|
+
], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
128
|
+
let lineBuffer = "";
|
|
129
|
+
const publishLine = (line) => {
|
|
130
|
+
const trimmed = line.trim();
|
|
131
|
+
if (!trimmed)
|
|
132
|
+
return;
|
|
133
|
+
const msg = {
|
|
134
|
+
id: crypto.randomUUID(),
|
|
135
|
+
source: "claude",
|
|
136
|
+
role: "assistant",
|
|
137
|
+
content: trimmed,
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
chatId: 0,
|
|
140
|
+
};
|
|
141
|
+
wire.discord.publishOutgoing(ns, msg).catch((err) => {
|
|
142
|
+
console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
|
|
143
|
+
});
|
|
144
|
+
};
|
|
145
|
+
proc.stdout.on("data", (chunk) => {
|
|
146
|
+
lineBuffer += chunk.toString();
|
|
147
|
+
const lines = lineBuffer.split("\n");
|
|
148
|
+
lineBuffer = lines.pop() ?? "";
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
publishLine(line);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
proc.stderr.on("data", (chunk) => {
|
|
154
|
+
const text = chunk.toString().trim();
|
|
155
|
+
if (text)
|
|
156
|
+
console.log(`[meta-agent-manager:${ns}:stderr] ${text}`);
|
|
157
|
+
});
|
|
158
|
+
proc.on("exit", (code) => {
|
|
159
|
+
// Flush any remaining buffered content
|
|
160
|
+
if (lineBuffer.trim())
|
|
161
|
+
publishLine(lineBuffer);
|
|
162
|
+
lineBuffer = "";
|
|
163
|
+
console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
|
|
164
|
+
resolve();
|
|
165
|
+
});
|
|
166
|
+
proc.on("error", (err) => {
|
|
167
|
+
console.error(`[meta-agent-manager] spawn error (ns=${ns}):`, err.message);
|
|
168
|
+
reject(err);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Create a MetaAgentManager that polls input queues and spawns sessions.
|
|
174
|
+
*/
|
|
175
|
+
export function createMetaAgentManager() {
|
|
176
|
+
let pollInterval = null;
|
|
177
|
+
const activeNamespaces = new Set();
|
|
178
|
+
return {
|
|
179
|
+
async ensureWorkspace(ns, repoUrl) {
|
|
180
|
+
await ensureWorkspace(ns, repoUrl);
|
|
181
|
+
},
|
|
182
|
+
injectMcp(ns, token) {
|
|
183
|
+
const wsPath = workspacePath(ns);
|
|
184
|
+
injectMcp(ns, wsPath, token);
|
|
185
|
+
},
|
|
186
|
+
startPolling(wire, getNamespaces) {
|
|
187
|
+
if (pollInterval)
|
|
188
|
+
return; // already running
|
|
189
|
+
pollInterval = setInterval(() => {
|
|
190
|
+
const namespaces = getNamespaces();
|
|
191
|
+
if (namespaces.length === 0)
|
|
192
|
+
return;
|
|
193
|
+
for (const ns of namespaces) {
|
|
194
|
+
if (activeNamespaces.has(ns))
|
|
195
|
+
continue;
|
|
196
|
+
wire.discord.dequeue(ns)
|
|
197
|
+
.then(async (msg) => {
|
|
198
|
+
if (!msg)
|
|
199
|
+
return;
|
|
200
|
+
const content = typeof msg === "string" ? msg : msg.content ?? String(msg);
|
|
201
|
+
activeNamespaces.add(ns);
|
|
202
|
+
await wire.discord.setStatus(ns, {
|
|
203
|
+
namespace: ns,
|
|
204
|
+
status: "running",
|
|
205
|
+
isTyping: true,
|
|
206
|
+
turnCount: 0,
|
|
207
|
+
updatedAt: new Date().toISOString(),
|
|
208
|
+
});
|
|
209
|
+
let token;
|
|
210
|
+
try {
|
|
211
|
+
token = await wire.token.getMaster();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
token = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
|
215
|
+
?? process.env.CLAUDE_CODE_TOKEN
|
|
216
|
+
?? process.env.ANTHROPIC_API_KEY
|
|
217
|
+
?? "";
|
|
218
|
+
}
|
|
219
|
+
if (!token) {
|
|
220
|
+
console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
|
|
221
|
+
activeNamespaces.delete(ns);
|
|
222
|
+
await wire.discord.setStatus(ns, {
|
|
223
|
+
namespace: ns,
|
|
224
|
+
status: "idle",
|
|
225
|
+
isTyping: false,
|
|
226
|
+
turnCount: 0,
|
|
227
|
+
updatedAt: new Date().toISOString(),
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
spawnSession(ns, content, token, wire)
|
|
232
|
+
.catch((err) => {
|
|
233
|
+
console.error(`[meta-agent-manager] session error (ns=${ns}):`, err.message);
|
|
234
|
+
})
|
|
235
|
+
.finally(() => {
|
|
236
|
+
activeNamespaces.delete(ns);
|
|
237
|
+
wire.discord.setStatus(ns, {
|
|
238
|
+
namespace: ns,
|
|
239
|
+
status: "idle",
|
|
240
|
+
isTyping: false,
|
|
241
|
+
turnCount: 0,
|
|
242
|
+
updatedAt: new Date().toISOString(),
|
|
243
|
+
}).catch(() => { });
|
|
244
|
+
});
|
|
245
|
+
})
|
|
246
|
+
.catch((err) => {
|
|
247
|
+
console.warn(`[meta-agent-manager] dequeue error (ns=${ns}):`, err.message);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}, TIMING.INPUT_POLL_INTERVAL_MS);
|
|
251
|
+
console.log(`[meta-agent-manager] polling started (interval=${TIMING.INPUT_POLL_INTERVAL_MS}ms)`);
|
|
252
|
+
},
|
|
253
|
+
stop() {
|
|
254
|
+
if (pollInterval) {
|
|
255
|
+
clearInterval(pollInterval);
|
|
256
|
+
pollInterval = null;
|
|
257
|
+
console.log("[meta-agent-manager] polling stopped");
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Migrate the old cc-agent meta input key to the new cc-discord key.
|
|
264
|
+
* Old: cca:meta:{ns}:input → New: cca:discord:meta:{ns}:input
|
|
265
|
+
*
|
|
266
|
+
* Called once on startup.
|
|
267
|
+
*/
|
|
268
|
+
export async function migrateMetaInputKeys(redis) {
|
|
269
|
+
let oldKeys;
|
|
270
|
+
try {
|
|
271
|
+
oldKeys = await redis.keys("cca:meta:*:input");
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
console.warn("[meta-agent-manager] migrateMetaInputKeys keys scan failed:", err.message);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const key of oldKeys) {
|
|
278
|
+
const match = key.match(/^cca:meta:(.+):input$/);
|
|
279
|
+
if (!match)
|
|
280
|
+
continue;
|
|
281
|
+
const ns = match[1];
|
|
282
|
+
const newKey = discordMetaInputKey(ns);
|
|
283
|
+
try {
|
|
284
|
+
const items = await redis.lrange(key, 0, -1);
|
|
285
|
+
if (items.length > 0) {
|
|
286
|
+
// lrange returns newest-first; reverse to maintain enqueue order
|
|
287
|
+
await redis.rpush(newKey, ...items.reverse());
|
|
288
|
+
console.log(`[meta-agent-manager] migrated ${items.length} items: ${key} → ${newKey}`);
|
|
289
|
+
}
|
|
290
|
+
await redis.del(key);
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
console.warn(`[meta-agent-manager] migration failed for ${key}:`, err.message);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
package/dist/notifier.d.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DiscordNotifier — subscribes to Redis pub/sub channels and bridges messages to Discord.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* cca:notify:{
|
|
6
|
-
* cca:chat:incoming:{
|
|
7
|
-
* cca:chat:outgoing
|
|
4
|
+
* v0.2.0 channels (discord-scoped):
|
|
5
|
+
* cca:discord:notify:{ns} — job completion notifications → forward to Discord channel
|
|
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
|
|
8
8
|
*
|
|
9
9
|
* All messages (Discord incoming, Claude responses) are also written to:
|
|
10
|
-
* cca:chat:log:{
|
|
11
|
-
* cca:chat:outgoing:{
|
|
10
|
+
* cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
|
+
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
13
|
import { Redis } from "ioredis";
|
|
14
14
|
import type { CcDiscordBot } from "./bot.js";
|
|
@@ -34,6 +34,7 @@ export interface ParsedNotification {
|
|
|
34
34
|
export declare function parseNotification(raw: string): ParsedNotification | null;
|
|
35
35
|
/**
|
|
36
36
|
* Write a message to the chat log in Redis.
|
|
37
|
+
* Uses discord-scoped keys (cca:discord:chat:log:{ns}, cca:discord:chat:outgoing:{ns}).
|
|
37
38
|
* Fire-and-forget — errors are logged but not thrown.
|
|
38
39
|
*/
|
|
39
40
|
export declare function writeChatLog(redis: Redis, namespace: string, msg: ChatMessage): void;
|
|
@@ -48,7 +49,7 @@ export interface NotifierHandle {
|
|
|
48
49
|
* Register the originating Discord channel ID for a routed namespace.
|
|
49
50
|
* When the meta-agent for `namespace` publishes a response, it will be
|
|
50
51
|
* forwarded to `channelId`.
|
|
51
|
-
* Also subscribes to
|
|
52
|
+
* Also subscribes to discordNotify(namespace) and chatIncomingChannel(namespace)
|
|
52
53
|
* so notifications and UI messages for that namespace are received.
|
|
53
54
|
*/
|
|
54
55
|
registerRoutedChannelId: (namespace: string, channelId: string) => void;
|
|
@@ -58,7 +59,7 @@ export interface NotifierHandle {
|
|
|
58
59
|
*
|
|
59
60
|
* @param bot - CcDiscordBot instance (for sending messages)
|
|
60
61
|
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
61
|
-
* @param namespace -
|
|
62
|
+
* @param namespace - primary namespace (used to build Redis channel names)
|
|
62
63
|
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
63
64
|
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
64
65
|
* @param forwardNotification - Optional callback to forward job notifications
|
package/dist/notifier.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* DiscordNotifier — subscribes to Redis pub/sub channels and bridges messages to Discord.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* cca:notify:{
|
|
6
|
-
* cca:chat:incoming:{
|
|
7
|
-
* cca:chat:outgoing
|
|
4
|
+
* v0.2.0 channels (discord-scoped):
|
|
5
|
+
* cca:discord:notify:{ns} — job completion notifications → forward to Discord channel
|
|
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
|
|
8
8
|
*
|
|
9
9
|
* All messages (Discord incoming, Claude responses) are also written to:
|
|
10
|
-
* cca:chat:log:{
|
|
11
|
-
* cca:chat:outgoing:{
|
|
10
|
+
* cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
|
|
11
|
+
* cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { discordChatLog, discordChatOutgoing, discordNotify, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
|
|
14
14
|
import { splitLongMessage, stripAnsi } from "./formatter.js";
|
|
15
15
|
function log(level, ...args) {
|
|
16
16
|
const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
@@ -70,11 +70,12 @@ export function parseNotification(raw) {
|
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* Write a message to the chat log in Redis.
|
|
73
|
+
* Uses discord-scoped keys (cca:discord:chat:log:{ns}, cca:discord:chat:outgoing:{ns}).
|
|
73
74
|
* Fire-and-forget — errors are logged but not thrown.
|
|
74
75
|
*/
|
|
75
76
|
export function writeChatLog(redis, namespace, msg) {
|
|
76
|
-
const logKey =
|
|
77
|
-
const outKey =
|
|
77
|
+
const logKey = discordChatLog(namespace);
|
|
78
|
+
const outKey = discordChatOutgoing(namespace);
|
|
78
79
|
const payload = JSON.stringify(msg);
|
|
79
80
|
redis.lpush(logKey, payload).catch((err) => {
|
|
80
81
|
log("warn", "writeChatLog lpush failed:", err.message);
|
|
@@ -104,7 +105,7 @@ export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId
|
|
|
104
105
|
*
|
|
105
106
|
* @param bot - CcDiscordBot instance (for sending messages)
|
|
106
107
|
* @param notifyChannelId - Discord channel ID to forward notifications to. Pass null to use getActiveChannelId.
|
|
107
|
-
* @param namespace -
|
|
108
|
+
* @param namespace - primary namespace (used to build Redis channel names)
|
|
108
109
|
* @param redis - ioredis client in normal mode (will be duplicated for pub/sub)
|
|
109
110
|
* @param handleUserMessage - Optional callback to feed UI messages into the active Claude session
|
|
110
111
|
* @param forwardNotification - Optional callback to forward job notifications
|
|
@@ -112,6 +113,7 @@ export function resolveNotifyChannel(chatId, notifyChannelId, getActiveChannelId
|
|
|
112
113
|
* @param reverseSnowflakeLookup - Optional callback to resolve a chatId integer to a Discord channelId
|
|
113
114
|
*/
|
|
114
115
|
export function startNotifier(bot, notifyChannelId, namespace, redis, handleUserMessage, forwardNotification, getActiveChannelId, reverseSnowflakeLookup) {
|
|
116
|
+
const wire = createCcWire(redis);
|
|
115
117
|
// Per-namespace channelId registry — maps routed namespace → Discord channelId
|
|
116
118
|
const routedChannelIds = new Map();
|
|
117
119
|
// Track which namespaces we've already subscribed to (to avoid duplicate subscribe calls)
|
|
@@ -135,7 +137,7 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
135
137
|
if (subscribedNamespaces.has(ns))
|
|
136
138
|
return;
|
|
137
139
|
subscribedNamespaces.add(ns);
|
|
138
|
-
const notifyCh =
|
|
140
|
+
const notifyCh = discordNotify(ns);
|
|
139
141
|
const incomingCh = chatIncomingChannel(ns);
|
|
140
142
|
channelToNamespace.set(notifyCh, ns);
|
|
141
143
|
channelToNamespace.set(incomingCh, ns);
|
|
@@ -161,17 +163,19 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
161
163
|
}
|
|
162
164
|
// Subscribe to the primary namespace immediately
|
|
163
165
|
subscribeNamespace(namespace);
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
+
// discordChatOutgoing("*") — meta-agent stdout lines for ALL discord namespaces
|
|
167
|
+
const outgoingPattern = discordChatOutgoing("*");
|
|
168
|
+
// Prefix to strip when extracting namespace from a matched channel name
|
|
169
|
+
const outgoingPrefix = discordChatOutgoing("");
|
|
170
|
+
sub.psubscribe(outgoingPattern, (err) => {
|
|
166
171
|
if (err) {
|
|
167
|
-
log("error", `psubscribe ${
|
|
172
|
+
log("error", `psubscribe ${outgoingPattern} failed:`, err.message);
|
|
168
173
|
}
|
|
169
174
|
else {
|
|
170
|
-
log("info", `psubscribed to ${
|
|
175
|
+
log("info", `psubscribed to ${outgoingPattern}`);
|
|
171
176
|
}
|
|
172
177
|
});
|
|
173
|
-
//
|
|
174
|
-
const META_AGENT_FLUSH_DELAY_MS = 1500;
|
|
178
|
+
// Buffer for meta-agent streaming output — debounced before sending to Discord
|
|
175
179
|
const metaAgentBuffers = new Map();
|
|
176
180
|
function flushMetaAgentBuffer(ns, targetChannelId) {
|
|
177
181
|
bot.stopMetaAgentTyping(targetChannelId);
|
|
@@ -190,7 +194,7 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
190
194
|
}
|
|
191
195
|
sub.on("pmessage", (pattern, channel, message) => {
|
|
192
196
|
void pattern;
|
|
193
|
-
const ns = channel.slice(
|
|
197
|
+
const ns = channel.slice(outgoingPrefix.length);
|
|
194
198
|
let parsed = null;
|
|
195
199
|
try {
|
|
196
200
|
parsed = JSON.parse(message);
|
|
@@ -203,8 +207,7 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
203
207
|
const content = parsed.content;
|
|
204
208
|
if (!content)
|
|
205
209
|
return;
|
|
206
|
-
// For the primary namespace: deliver to the primary Discord channel
|
|
207
|
-
// or the last-active channel). Responses go to BOTH Telegram (via cc-tg) AND Discord.
|
|
210
|
+
// For the primary namespace: deliver to the primary Discord channel.
|
|
208
211
|
// For other (routed) namespaces: only deliver to explicitly registered channelIds.
|
|
209
212
|
const targetChannelId = routedChannelIds.get(ns) ??
|
|
210
213
|
(ns === namespace ? (notifyChannelId ?? getActiveChannelId?.()) : undefined);
|
|
@@ -220,12 +223,12 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
220
223
|
buf.text += (buf.text ? "\n" : "") + content;
|
|
221
224
|
if (buf.timer)
|
|
222
225
|
clearTimeout(buf.timer);
|
|
223
|
-
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), META_AGENT_FLUSH_DELAY_MS);
|
|
226
|
+
buf.timer = setTimeout(() => flushMetaAgentBuffer(ns, targetChannelId), TIMING.META_AGENT_FLUSH_DELAY_MS);
|
|
224
227
|
});
|
|
225
|
-
// Poll
|
|
228
|
+
// Poll discordNotify(ns) LIST every 5 seconds — covers primary + all routed namespaces.
|
|
226
229
|
const MAX_PER_CYCLE = 20;
|
|
227
230
|
const pollOneNamespace = async (ns, targetChannelId) => {
|
|
228
|
-
const listKey =
|
|
231
|
+
const listKey = discordNotify(ns);
|
|
229
232
|
const items = [];
|
|
230
233
|
try {
|
|
231
234
|
for (let i = 0; i < MAX_PER_CYCLE; i++) {
|
|
@@ -294,7 +297,7 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
294
297
|
if (!ns)
|
|
295
298
|
return;
|
|
296
299
|
const isPrimary = ns === namespace;
|
|
297
|
-
const notifyCh =
|
|
300
|
+
const notifyCh = discordNotify(ns);
|
|
298
301
|
const incomingCh = chatIncomingChannel(ns);
|
|
299
302
|
if (channel === notifyCh) {
|
|
300
303
|
const notification = parseNotification(message);
|
|
@@ -351,30 +354,31 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
|
|
|
351
354
|
role: "user",
|
|
352
355
|
content,
|
|
353
356
|
timestamp: originalTimestamp ?? new Date().toISOString(),
|
|
354
|
-
chatId: 0,
|
|
357
|
+
chatId: 0,
|
|
355
358
|
};
|
|
356
359
|
writeChatLog(redis, ns, inMsg);
|
|
357
|
-
//
|
|
360
|
+
// Route to meta-agent input queue if this namespace has a registered session;
|
|
361
|
+
// otherwise fall through to handleUserMessage (local Claude session).
|
|
358
362
|
void (async () => {
|
|
359
363
|
let routedToMetaAgent = false;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const entry = JSON.stringify({
|
|
364
|
+
if (routedChannelIds.has(ns)) {
|
|
365
|
+
try {
|
|
366
|
+
const status = await wire.discord.getStatus(ns);
|
|
367
|
+
if (status && (status.status === "running" || status.status === "idle")) {
|
|
368
|
+
await wire.discord.enqueue(ns, {
|
|
366
369
|
id: crypto.randomUUID(),
|
|
370
|
+
source: "ui",
|
|
371
|
+
role: "user",
|
|
367
372
|
content,
|
|
368
373
|
timestamp: new Date().toISOString(),
|
|
369
374
|
});
|
|
370
|
-
await redis.rpush(metaInputKey(ns), entry);
|
|
371
375
|
log("info", `cca:chat:incoming: routed to meta-agent for namespace ${ns}`);
|
|
372
376
|
routedToMetaAgent = true;
|
|
373
377
|
}
|
|
374
378
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
379
|
+
catch (err) {
|
|
380
|
+
log("warn", `meta-agent status check failed (ns=${ns}):`, err.message);
|
|
381
|
+
}
|
|
378
382
|
}
|
|
379
383
|
if (!routedToMetaAgent && handleUserMessage) {
|
|
380
384
|
handleUserMessage(targetChannelId, content);
|
package/dist/router.d.ts
CHANGED
|
@@ -1,30 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Routing helpers: channel-creation intent detection and meta-agent routing.
|
|
3
|
-
*/
|
|
4
|
-
import { Redis } from "ioredis";
|
|
5
|
-
/** Callback type matching CcDiscordBot.callCcAgentTool */
|
|
6
|
-
export type CallToolFn = (toolName: string, args?: Record<string, unknown>) => Promise<string | null>;
|
|
7
|
-
/**
|
|
8
|
-
* Ensure a meta-agent for the given namespace is running.
|
|
9
|
-
*
|
|
10
|
-
* Steps:
|
|
11
|
-
* 1. Check Redis for readiness via two keys (see below) — return early if already ready.
|
|
12
|
-
* 2. Verify the GitHub repo exists; create it (public) if not.
|
|
13
|
-
* 3. Call the start_meta_agent MCP tool via callTool.
|
|
14
|
-
* 4. Poll both Redis keys every 1s until ready or META_AGENT_TIMEOUT_MS expires.
|
|
15
3
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* cca:meta:{namespace} — state key written by startMetaAgent() directly via saveState()
|
|
20
|
-
* (populated as soon as the workspace is created, with status:"idle")
|
|
21
|
-
*
|
|
22
|
-
* Bug context: start_meta_agent writes cca:meta:{namespace} but NOT cca:meta-agent:status:{namespace}.
|
|
23
|
-
* Polling only the status key caused a 10s timeout on every cold start.
|
|
24
|
-
*
|
|
25
|
-
* Throws on failure (repo creation error, tool call failure, or timeout).
|
|
4
|
+
* v0.2.0: cc-discord owns meta-agent lifecycle directly.
|
|
5
|
+
* routeToMetaAgent now writes to the discord-scoped input key (cca:discord:meta:{ns}:input).
|
|
6
|
+
* ensureMetaAgent has been removed — use MetaAgentManager.ensureWorkspace instead.
|
|
26
7
|
*/
|
|
27
|
-
|
|
8
|
+
import { Redis } from "ioredis";
|
|
28
9
|
/**
|
|
29
10
|
* Detect a natural-language channel-creation request.
|
|
30
11
|
* Matches:
|
|
@@ -40,8 +21,8 @@ export declare function parseChannelCreateIntent(text: string): {
|
|
|
40
21
|
} | null;
|
|
41
22
|
/**
|
|
42
23
|
* Route a message to a running meta-agent via Redis RPUSH.
|
|
43
|
-
*
|
|
24
|
+
* cc-discord polls cca:discord:meta:{namespace}:input every 3s.
|
|
44
25
|
*
|
|
45
|
-
* No-op when strippedMessage is empty
|
|
26
|
+
* No-op when strippedMessage is empty.
|
|
46
27
|
*/
|
|
47
28
|
export declare function routeToMetaAgent(namespace: string, strippedMessage: string, redis: Redis): Promise<void>;
|
package/dist/router.js
CHANGED
|
@@ -1,133 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Routing helpers: channel-creation intent detection and meta-agent routing.
|
|
3
|
-
*/
|
|
4
|
-
import { execSync } from "child_process";
|
|
5
|
-
import { metaAgentStatusKey, metaKey, metaInputKey } from "@gonzih/cc-wire";
|
|
6
|
-
/**
|
|
7
|
-
* Ensure a meta-agent for the given namespace is running.
|
|
8
|
-
*
|
|
9
|
-
* Steps:
|
|
10
|
-
* 1. Check Redis for readiness via two keys (see below) — return early if already ready.
|
|
11
|
-
* 2. Verify the GitHub repo exists; create it (public) if not.
|
|
12
|
-
* 3. Call the start_meta_agent MCP tool via callTool.
|
|
13
|
-
* 4. Poll both Redis keys every 1s until ready or META_AGENT_TIMEOUT_MS expires.
|
|
14
3
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* cca:meta:{namespace} — state key written by startMetaAgent() directly via saveState()
|
|
19
|
-
* (populated as soon as the workspace is created, with status:"idle")
|
|
20
|
-
*
|
|
21
|
-
* Bug context: start_meta_agent writes cca:meta:{namespace} but NOT cca:meta-agent:status:{namespace}.
|
|
22
|
-
* Polling only the status key caused a 10s timeout on every cold start.
|
|
23
|
-
*
|
|
24
|
-
* Throws on failure (repo creation error, tool call failure, or timeout).
|
|
4
|
+
* v0.2.0: cc-discord owns meta-agent lifecycle directly.
|
|
5
|
+
* routeToMetaAgent now writes to the discord-scoped input key (cca:discord:meta:{ns}:input).
|
|
6
|
+
* ensureMetaAgent has been removed — use MetaAgentManager.ensureWorkspace instead.
|
|
25
7
|
*/
|
|
26
|
-
|
|
27
|
-
const timeoutMs = parseInt(process.env.META_AGENT_TIMEOUT_MS ?? "10000", 10);
|
|
28
|
-
const statusKey = metaAgentStatusKey(namespace);
|
|
29
|
-
// State key written by startMetaAgent() directly — the source of truth for workspace existence.
|
|
30
|
-
const stateKey = metaKey(namespace);
|
|
31
|
-
console.log(`[router] ensureMetaAgent namespace=${namespace}`);
|
|
32
|
-
// Fast path: check live-status key (written by messageMetaAgent after first message)
|
|
33
|
-
const statusRaw = await redis.get(statusKey);
|
|
34
|
-
if (statusRaw) {
|
|
35
|
-
try {
|
|
36
|
-
const status = JSON.parse(statusRaw);
|
|
37
|
-
if (status.status === "running" || status.status === "idle") {
|
|
38
|
-
console.log(`[router] meta-agent ${namespace} is already ready (status=${status.status})`);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
// Corrupt status value — fall through
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
// Fast path: also check state key (written by startMetaAgent, persists 30 days).
|
|
47
|
-
// Presence of this key means the workspace was already created — no need to re-run start_meta_agent.
|
|
48
|
-
const stateRaw = await redis.get(stateKey);
|
|
49
|
-
if (stateRaw) {
|
|
50
|
-
try {
|
|
51
|
-
const state = JSON.parse(stateRaw);
|
|
52
|
-
if (state.status === "idle" || state.status === "running") {
|
|
53
|
-
console.log(`[router] meta-agent ${namespace} workspace exists (state.status=${state.status})`);
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
// Corrupt state — fall through and re-initialize
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
// Derive "org/repo" from the full URL for gh CLI calls
|
|
62
|
-
const orgRepo = repoUrl.replace(/^https:\/\/github\.com\//, "");
|
|
63
|
-
// Verify / create the GitHub repo
|
|
64
|
-
try {
|
|
65
|
-
execSync(`gh repo view ${orgRepo}`, { stdio: "ignore" });
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
// Repo not found — create it
|
|
69
|
-
try {
|
|
70
|
-
execSync(`gh repo create ${orgRepo} --public --description "Meta-agent workspace for ${namespace}"`, { stdio: "pipe" });
|
|
71
|
-
console.log(`[router] created repo ${orgRepo} for namespace=${namespace}`);
|
|
72
|
-
}
|
|
73
|
-
catch (createErr) {
|
|
74
|
-
throw new Error(`Failed to create repo ${orgRepo}: ${createErr.message}`);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
// Start the meta-agent via MCP (clones workspace if needed, writes cca:meta:{namespace})
|
|
78
|
-
const result = await callTool("start_meta_agent", { namespace, repo_url: repoUrl });
|
|
79
|
-
if (result === null) {
|
|
80
|
-
throw new Error(`start_meta_agent returned null — tool may not be available in cc-agent`);
|
|
81
|
-
}
|
|
82
|
-
// Check for explicit failure payload (e.g. git clone error)
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(result);
|
|
85
|
-
if (parsed.ok === false) {
|
|
86
|
-
throw new Error(`start_meta_agent failed: ${parsed.error ?? "unknown error"}`);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
catch (jsonErr) {
|
|
90
|
-
if (!(jsonErr instanceof SyntaxError))
|
|
91
|
-
throw jsonErr;
|
|
92
|
-
// Non-JSON result (e.g. plain "ok") — not an error, continue to poll
|
|
93
|
-
}
|
|
94
|
-
// Poll until ready. Check both keys:
|
|
95
|
-
// - statusKey: written by writeLiveStatus() during messageMetaAgent (may not exist yet on cold start)
|
|
96
|
-
// - stateKey: written by startMetaAgent() above — will appear within 1s of the tool call returning
|
|
97
|
-
const deadline = Date.now() + timeoutMs;
|
|
98
|
-
while (Date.now() < deadline) {
|
|
99
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
100
|
-
const raw = await redis.get(statusKey);
|
|
101
|
-
if (raw) {
|
|
102
|
-
try {
|
|
103
|
-
const s = JSON.parse(raw);
|
|
104
|
-
console.log(`[router] waiting for meta-agent ${namespace} — status key: ${s.status}`);
|
|
105
|
-
if (s.status === "running" || s.status === "idle")
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// ignore parse errors, keep polling
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
// Also check state key — startMetaAgent writes this synchronously before responding
|
|
113
|
-
const state = await redis.get(stateKey);
|
|
114
|
-
if (state) {
|
|
115
|
-
try {
|
|
116
|
-
const s = JSON.parse(state);
|
|
117
|
-
console.log(`[router] waiting for meta-agent ${namespace} — state key: ${s.status}`);
|
|
118
|
-
if (s.status === "idle" || s.status === "running")
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
catch {
|
|
122
|
-
// ignore parse errors, keep polling
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
console.log(`[router] waiting for meta-agent ${namespace} — neither key present yet`);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
throw new Error(`Meta-agent for ${namespace} did not become ready within ${timeoutMs}ms`);
|
|
130
|
-
}
|
|
8
|
+
import { discordMetaInputKey } from "@gonzih/cc-wire";
|
|
131
9
|
/**
|
|
132
10
|
* Detect a natural-language channel-creation request.
|
|
133
11
|
* Matches:
|
|
@@ -147,9 +25,9 @@ export function parseChannelCreateIntent(text) {
|
|
|
147
25
|
}
|
|
148
26
|
/**
|
|
149
27
|
* Route a message to a running meta-agent via Redis RPUSH.
|
|
150
|
-
*
|
|
28
|
+
* cc-discord polls cca:discord:meta:{namespace}:input every 3s.
|
|
151
29
|
*
|
|
152
|
-
* No-op when strippedMessage is empty
|
|
30
|
+
* No-op when strippedMessage is empty.
|
|
153
31
|
*/
|
|
154
32
|
export async function routeToMetaAgent(namespace, strippedMessage, redis) {
|
|
155
33
|
if (!strippedMessage)
|
|
@@ -159,7 +37,6 @@ export async function routeToMetaAgent(namespace, strippedMessage, redis) {
|
|
|
159
37
|
content: strippedMessage,
|
|
160
38
|
timestamp: new Date().toISOString(),
|
|
161
39
|
});
|
|
162
|
-
|
|
163
|
-
await redis.rpush(metaInputKey(namespace), entry);
|
|
40
|
+
await redis.rpush(discordMetaInputKey(namespace), entry);
|
|
164
41
|
console.log(`[router] routed message to meta-agent namespace=${namespace}`);
|
|
165
42
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gonzih/cc-discord",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Claude Code Discord bot — chat with Claude Code via Discord",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"dist/"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@gonzih/cc-wire": "^0.
|
|
21
|
+
"@gonzih/cc-wire": "^0.3.0",
|
|
22
22
|
"discord.js": "^14.0.0",
|
|
23
23
|
"ioredis": "^5.0.0"
|
|
24
24
|
},
|