@gonzih/cc-discord 0.1.17 → 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 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, ensureMetaAgent, routeToMetaAgent } from "./router.js";
17
- /** Redis key for persisting a Discord channelId → namespace mapping across restarts. */
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.redis)
192
+ if (!this.wire)
189
193
  return;
190
- const key = discordChannelKey(channelId);
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.redis)
203
+ if (!this.wire)
202
204
  return;
203
- let keys;
205
+ let channels;
204
206
  try {
205
- keys = await this.redis.keys("cca:discord:channel:*");
207
+ channels = await this.wire.discord.listChannels();
206
208
  }
207
209
  catch (err) {
208
- console.warn("[bot] loadChannelMappings keys scan failed:", err.message);
210
+ console.warn("[bot] loadChannelMappings failed:", err.message);
209
211
  return;
210
212
  }
211
- for (const key of keys) {
212
- try {
213
- const raw = await this.redis.get(key);
214
- if (!raw)
215
- continue;
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
  }
@@ -832,14 +824,16 @@ export class CcDiscordBot {
832
824
  this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
833
825
  this.persistChannelMapping(newChannel.id, namespace, repoUrl);
834
826
  await interaction.editReply(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`);
835
- // Start meta-agent in the background
836
- if (this.redis) {
837
- ensureMetaAgent(namespace, repoUrl, (toolName, args) => this.callCcAgentTool(toolName, args ?? {}), this.redis)
838
- .catch((err) => {
839
- console.error(`[bot] /channel ensureMetaAgent(${namespace}) failed:`, err.message);
840
- this.sendToChannelById(newChannel.id, `Warning: meta-agent startup failed — ${err.message}`).catch(() => { });
841
- });
842
- }
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
+ });
843
837
  }
844
838
  catch (err) {
845
839
  await interaction.editReply(`Failed to create channel: ${err.message}`);
@@ -976,14 +970,16 @@ export class CcDiscordBot {
976
970
  this.opts.registerRoutedChannelId?.(namespace, newChannel.id);
977
971
  this.persistChannelMapping(newChannel.id, namespace, repoUrl);
978
972
  await channel.send(`Created <#${newChannel.id}> — messages there route to the ${repoUrl} meta-agent`).catch(() => { });
979
- // Start meta-agent in the background after acknowledging the user
980
- if (this.redis) {
981
- ensureMetaAgent(namespace, repoUrl, (toolName, args) => this.callCcAgentTool(toolName, args ?? {}), this.redis)
982
- .catch((err) => {
983
- console.error(`[bot] ensureMetaAgent(${namespace}) failed:`, err.message);
984
- this.sendToChannelById(newChannel.id, `Warning: meta-agent startup failed — ${err.message}`).catch(() => { });
985
- });
986
- }
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
+ });
987
983
  }
988
984
  /** Write a message to the Redis chat log. Fire-and-forget.
989
985
  * Pass `ns` to write under a specific namespace; defaults to the bot's primary namespace. */
@@ -1043,6 +1039,32 @@ export class CcDiscordBot {
1043
1039
  console.error(`[forwardNotification:${channelId}] failed:`, err.message);
1044
1040
  }
1045
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
+ }
1046
1068
  stop() {
1047
1069
  for (const [key, session] of this.sessions) {
1048
1070
  if (session.flushTimer)
@@ -1055,6 +1077,7 @@ export class CcDiscordBot {
1055
1077
  for (const [channelId] of this.metaAgentTypingTimers) {
1056
1078
  this.stopMetaAgentTyping(channelId);
1057
1079
  }
1080
+ this.metaAgentManager.stop();
1058
1081
  void this.client.destroy();
1059
1082
  }
1060
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 — cc-agent namespace (default: money-brain)
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 — cc-agent namespace (default: money-brain)
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 this version on Redis so other services can discover cc-discord
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
+ }
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * DiscordNotifier — subscribes to Redis pub/sub channels and bridges messages to Discord.
3
3
  *
4
- * Channels:
5
- * cca:notify:{namespace} — job completion notifications from cc-agent → forward to DISCORD_NOTIFY_CHANNEL_ID
6
- * cca:chat:incoming:{namespace} — messages from the web UI → echo to Discord + feed into Claude session
7
- * cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
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:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
11
- * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
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 notifyChannel(namespace) and chatIncomingChannel(namespace)
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 - cc-agent namespace (used to build Redis channel names)
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
- * Channels:
5
- * cca:notify:{namespace} — job completion notifications from cc-agent → forward to DISCORD_NOTIFY_CHANNEL_ID
6
- * cca:chat:incoming:{namespace} — messages from the web UI → echo to Discord + feed into Claude session
7
- * cca:chat:outgoing:* — meta-agent stdout lines (source=claude) → buffer+debounce → Discord
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:{namespace} — LPUSH + LTRIM 0 499 (last 500 messages)
11
- * cca:chat:outgoing:{namespace} — PUBLISH for web UI to consume
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 { chatLogKey, chatOutgoingChannel, chatIncomingChannel, notifyChannel, notifyListKey, metaAgentStatusKey, metaInputKey, } from "@gonzih/cc-wire";
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 = chatLogKey(namespace);
77
- const outKey = chatOutgoingChannel(namespace);
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 - cc-agent namespace (used to build Redis channel names)
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 = notifyChannel(ns);
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
- // chatOutgoingChannel("*") — meta-agent stdout lines for ALL namespaces
165
- sub.psubscribe(chatOutgoingChannel("*"), (err) => {
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 ${chatOutgoingChannel("*")} failed:`, err.message);
172
+ log("error", `psubscribe ${outgoingPattern} failed:`, err.message);
168
173
  }
169
174
  else {
170
- log("info", `psubscribed to ${chatOutgoingChannel("*")}`);
175
+ log("info", `psubscribed to ${outgoingPattern}`);
171
176
  }
172
177
  });
173
- // 1.5s silence buffer for meta-agent streaming
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(chatOutgoingChannel("").length);
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 (DISCORD_NOTIFY_CHANNEL_ID
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 notifyListKey(ns) LIST every 5 seconds — covers primary + all routed namespaces.
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 = notifyListKey(ns);
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 = notifyChannel(ns);
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, // no numeric chatId for Discord — stored by channelId string
357
+ chatId: 0,
355
358
  };
356
359
  writeChatLog(redis, ns, inMsg);
357
- // Check if a meta-agent is running; if so, route there instead
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
- try {
361
- const statusRaw = await redis.get(metaAgentStatusKey(ns));
362
- if (statusRaw) {
363
- const status = JSON.parse(statusRaw);
364
- if (status.status === "running") {
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
- catch (err) {
377
- log("warn", `meta-agent status check failed (ns=${ns}):`, err.message);
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
- * Two Redis keys are checked:
17
- * cca:meta-agent:status:{namespace} live-status key written by writeLiveStatus()
18
- * (only populated after the first message is processed by messageMetaAgent)
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
- export declare function ensureMetaAgent(namespace: string, repoUrl: string, callTool: CallToolFn, redis: Redis): Promise<void>;
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
- * The cc-agent polls cca:meta:{namespace}:input every 3s (up to 3s delivery latency).
24
+ * cc-discord polls cca:discord:meta:{namespace}:input every 3s.
44
25
  *
45
- * No-op when strippedMessage is empty (user sent only the tag token).
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
- * Two Redis keys are checked:
16
- * cca:meta-agent:status:{namespace} live-status key written by writeLiveStatus()
17
- * (only populated after the first message is processed by messageMetaAgent)
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
- export async function ensureMetaAgent(namespace, repoUrl, callTool, redis) {
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
- * The cc-agent polls cca:meta:{namespace}:input every 3s (up to 3s delivery latency).
28
+ * cc-discord polls cca:discord:meta:{namespace}:input every 3s.
151
29
  *
152
- * No-op when strippedMessage is empty (user sent only the tag token).
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
- // FIFO — cc-agent reads via LPOP
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.1.17",
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.1.6",
21
+ "@gonzih/cc-wire": "^0.3.0",
22
22
  "discord.js": "^14.0.0",
23
23
  "ioredis": "^5.0.0"
24
24
  },