@gonzih/cc-discord 0.2.15 → 0.2.17

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
@@ -18,6 +18,8 @@ export interface DiscordBotOptions {
18
18
  redis?: Redis;
19
19
  namespace?: string;
20
20
  guildIds?: string[];
21
+ /** Instance ID for singleton overlap detection */
22
+ instanceId?: string;
21
23
  /** Called when a message is routed to a non-default namespace so the notifier
22
24
  * can forward the response back to the originating Discord channel. */
23
25
  registerRoutedChannelId?: (namespace: string, channelId: string) => void;
@@ -138,5 +140,22 @@ export declare class CcDiscordBot {
138
140
  * Passes a live getter for the set of registered namespaces.
139
141
  */
140
142
  startMetaAgentPolling(): void;
143
+ /**
144
+ * Resolve the namespace for a given channelId.
145
+ * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
146
+ */
147
+ private resolveNamespaceForChannel;
148
+ /**
149
+ * Delete all Claude session JSONL files for the given namespace's workspace.
150
+ * Claude stores sessions in ~/.claude/projects/{encoded-workspace-path}/*.jsonl
151
+ * The path encoding replaces / with - and prepends a leading -.
152
+ * Returns the number of files deleted.
153
+ */
154
+ private clearClaudeSession;
155
+ /**
156
+ * Send /compact as a prompt to a one-shot claude --continue session in the namespace workspace.
157
+ * This triggers Claude's built-in context compaction.
158
+ */
159
+ private compactClaudeSession;
141
160
  stop(): void;
142
161
  }
package/dist/bot.js CHANGED
@@ -3,8 +3,9 @@
3
3
  * One ClaudeProcess per channel (or channel:thread) — sessions are isolated per channel.
4
4
  */
5
5
  import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, EmbedBuilder, AttachmentBuilder, Events, ChannelType, } from "discord.js";
6
- import { existsSync, createWriteStream, mkdirSync, statSync } from "fs";
6
+ import { existsSync, createWriteStream, mkdirSync, statSync, readdirSync, rmSync } from "fs";
7
7
  import { resolve, basename, join } from "path";
8
+ import { homedir } from "os";
8
9
  import https from "https";
9
10
  import http from "http";
10
11
  import { createCcWire } from "@gonzih/cc-wire";
@@ -816,6 +817,15 @@ export class CcDiscordBot {
816
817
  .setName("channel")
817
818
  .setDescription("Create a Discord channel for a GitHub repo meta-agent")
818
819
  .addStringOption((opt) => opt.setName("repo").setDescription("GitHub repo URL (e.g. https://github.com/org/repo)").setRequired(true)),
820
+ new SlashCommandBuilder()
821
+ .setName("restart")
822
+ .setDescription("Graceful restart — exits this process (launchd will respawn clean)"),
823
+ new SlashCommandBuilder()
824
+ .setName("clear")
825
+ .setDescription("Clear the Claude session context for this channel's namespace"),
826
+ new SlashCommandBuilder()
827
+ .setName("compact")
828
+ .setDescription("Compact the Claude session context for this channel's namespace"),
819
829
  ].map((cmd) => cmd.toJSON());
820
830
  const rest = new REST().setToken(this.opts.discordToken);
821
831
  if (this.opts.guildIds?.length) {
@@ -941,6 +951,27 @@ export class CcDiscordBot {
941
951
  }
942
952
  break;
943
953
  }
954
+ case "restart": {
955
+ await interaction.reply("Restarting...");
956
+ setTimeout(() => { process.exit(0); }, 500);
957
+ break;
958
+ }
959
+ case "clear": {
960
+ const ns = this.resolveNamespaceForChannel(channelId);
961
+ const deleted = this.clearClaudeSession(ns);
962
+ await interaction.reply(deleted > 0
963
+ ? `Context cleared for ${ns} (${deleted} session file${deleted === 1 ? "" : "s"} removed). Next message starts fresh.`
964
+ : `No active session files found for ${ns}.`);
965
+ break;
966
+ }
967
+ case "compact": {
968
+ const ns = this.resolveNamespaceForChannel(channelId);
969
+ await interaction.reply(`Compacting context for ${ns}...`);
970
+ this.compactClaudeSession(ns).catch((err) => {
971
+ console.warn(`[bot] /compact failed (ns=${ns}):`, err.message);
972
+ });
973
+ break;
974
+ }
944
975
  }
945
976
  }
946
977
  async handleCronsCommand(interaction, channelId) {
@@ -1293,7 +1324,90 @@ export class CcDiscordBot {
1293
1324
  startMetaAgentPolling() {
1294
1325
  if (!this.wire)
1295
1326
  return;
1296
- this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()));
1327
+ this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()), this.opts.instanceId);
1328
+ }
1329
+ /**
1330
+ * Resolve the namespace for a given channelId.
1331
+ * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
1332
+ */
1333
+ resolveNamespaceForChannel(channelId) {
1334
+ return this.channelNamespaceMap.get(channelId)?.namespace ?? this.namespace;
1335
+ }
1336
+ /**
1337
+ * Delete all Claude session JSONL files for the given namespace's workspace.
1338
+ * Claude stores sessions in ~/.claude/projects/{encoded-workspace-path}/*.jsonl
1339
+ * The path encoding replaces / with - and prepends a leading -.
1340
+ * Returns the number of files deleted.
1341
+ */
1342
+ clearClaudeSession(ns) {
1343
+ const wsPath = join(homedir(), "cc-discord-workspace", ns);
1344
+ // Claude encodes workspace path: strip leading /, replace / with -, then prepend -
1345
+ const encoded = "-" + wsPath.slice(1).replace(/\//g, "-");
1346
+ const projectsDir = join(homedir(), ".claude", "projects", encoded);
1347
+ if (!existsSync(projectsDir))
1348
+ return 0;
1349
+ let count = 0;
1350
+ try {
1351
+ const entries = readdirSync(projectsDir);
1352
+ for (const entry of entries) {
1353
+ if (entry.endsWith(".jsonl")) {
1354
+ rmSync(join(projectsDir, entry), { force: true });
1355
+ count++;
1356
+ }
1357
+ }
1358
+ }
1359
+ catch (err) {
1360
+ console.warn(`[bot] clearClaudeSession(${ns}) failed:`, err.message);
1361
+ }
1362
+ console.log(`[bot] clearClaudeSession(${ns}): deleted ${count} files from ${projectsDir}`);
1363
+ return count;
1364
+ }
1365
+ /**
1366
+ * Send /compact as a prompt to a one-shot claude --continue session in the namespace workspace.
1367
+ * This triggers Claude's built-in context compaction.
1368
+ */
1369
+ async compactClaudeSession(ns) {
1370
+ const wsPath = join(homedir(), "cc-discord-workspace", ns);
1371
+ if (!existsSync(wsPath)) {
1372
+ console.warn(`[bot] compactClaudeSession(${ns}): workspace not found at ${wsPath}`);
1373
+ return;
1374
+ }
1375
+ const token = await this.resolveToken();
1376
+ const { spawn } = await import("child_process");
1377
+ const { existsSync: fsExists } = await import("fs");
1378
+ const resolveBin = () => {
1379
+ const dirs = (process.env.PATH ?? "").split(":");
1380
+ for (const dir of dirs) {
1381
+ const c = `${dir}/claude`;
1382
+ if (fsExists(c))
1383
+ return c;
1384
+ }
1385
+ for (const p of [`${homedir()}/.npm-global/bin/claude`, "/opt/homebrew/bin/claude", "/usr/local/bin/claude"]) {
1386
+ if (fsExists(p))
1387
+ return p;
1388
+ }
1389
+ return "claude";
1390
+ };
1391
+ const env = { ...process.env };
1392
+ if (token.startsWith("sk-ant-api")) {
1393
+ env.ANTHROPIC_API_KEY = token;
1394
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
1395
+ }
1396
+ else {
1397
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
1398
+ delete env.ANTHROPIC_API_KEY;
1399
+ }
1400
+ return new Promise((resolve) => {
1401
+ const proc = spawn(resolveBin(), ["--continue", "-p", "/compact", "--output-format", "text", "--dangerously-skip-permissions"], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
1402
+ proc.on("exit", (code) => {
1403
+ console.log(`[bot] compactClaudeSession(${ns}) exited code=${code}`);
1404
+ resolve();
1405
+ });
1406
+ proc.on("error", (err) => {
1407
+ console.warn(`[bot] compactClaudeSession(${ns}) spawn error:`, err.message);
1408
+ resolve();
1409
+ });
1410
+ });
1297
1411
  }
1298
1412
  stop() {
1299
1413
  for (const [key, session] of this.sessions) {
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@
20
20
  * CC_DISCORD_MCP_JSON — JSON template for .mcp.json injection into workspaces
21
21
  */
22
22
  import { createRequire } from "node:module";
23
+ import { randomUUID } from "crypto";
23
24
  import { Redis } from "ioredis";
24
25
  import { createCcWire } from "@gonzih/cc-wire";
25
26
  import { CcDiscordBot } from "./bot.js";
@@ -77,12 +78,28 @@ sharedRedis.on("error", (err) => {
77
78
  });
78
79
  // cc-wire factory
79
80
  const wire = createCcWire(sharedRedis);
81
+ // Singleton instance ID — used to detect stale processes after a restart
82
+ const instanceId = randomUUID();
83
+ const INSTANCE_KEY = "cca:discord:instance";
84
+ const INSTANCE_TTL_MS = 30_000;
85
+ const INSTANCE_REFRESH_MS = 10_000;
80
86
  sharedRedis.once("ready", () => {
81
87
  // Announce version
82
88
  sharedRedis.set(`cca:meta:cc-discord:version`, version).catch((err) => {
83
89
  console.warn("[redis] failed to write version:", err.message);
84
90
  });
85
91
  console.log(`[cc-discord] version:reported ${version}`);
92
+ // Write singleton instance ID with 30s TTL
93
+ sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
94
+ console.warn("[cc-discord] failed to write instance ID:", err.message);
95
+ });
96
+ console.log(`[cc-discord] instance:${instanceId}`);
97
+ // Refresh TTL every 10s so launchd-respawned processes displace old ones
98
+ setInterval(() => {
99
+ sharedRedis.set(INSTANCE_KEY, instanceId, "PX", INSTANCE_TTL_MS).catch((err) => {
100
+ console.warn("[cc-discord] instance refresh failed:", err.message);
101
+ });
102
+ }, INSTANCE_REFRESH_MS);
86
103
  // Store master token so MetaAgentManager can retrieve it
87
104
  wire.token.setMaster(claudeToken).catch((err) => {
88
105
  console.warn("[cc-discord] failed to set master token:", err.message);
@@ -140,6 +157,7 @@ const bot = new CcDiscordBot({
140
157
  guildIds,
141
158
  redis: sharedRedis,
142
159
  namespace,
160
+ instanceId,
143
161
  registerRoutedChannelId: (ns, channelId) => notifier.registerRoutedChannelId(ns, channelId),
144
162
  });
145
163
  const notifier = startNotifier(bot, notifyChannelId, namespace, sharedRedis, (channelId, text) => handleUserMessageFn?.(channelId, text), (channelId, text) => forwardNotificationFn?.(channelId, text), () => getLastActiveChannelIdFn(), (n) => bot.reverseSnowflakeLookup(n), (ns) => bot.getChannelIdForNamespace(ns));
@@ -30,9 +30,17 @@ export declare function ensureWorkspace(ns: string, repoUrl: string): Promise<vo
30
30
  * {npmCache}, {trustedOwners}, {path}.
31
31
  */
32
32
  export declare function injectMcp(ns: string, wsPath: string, token: string): void;
33
+ /**
34
+ * Redis keys for meta-agent stdout streaming.
35
+ * cca:meta:{ns}:stream — pub/sub channel (live streaming)
36
+ * cca:meta:{ns}:log — list (history, capped at 2000)
37
+ */
38
+ export declare function metaStreamChannel(ns: string): string;
39
+ export declare function metaLogKey(ns: string): string;
33
40
  /**
34
41
  * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
35
42
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
43
+ * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
36
44
  * Returns a Promise that resolves when the process exits.
37
45
  */
38
46
  export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
@@ -42,7 +50,7 @@ export interface MetaAgentManager {
42
50
  startPolling: (wire: Wire, getNamespaces: () => Array<{
43
51
  namespace: string;
44
52
  repoUrl: string;
45
- }>) => void;
53
+ }>, instanceId?: string) => void;
46
54
  stop: () => void;
47
55
  }
48
56
  /**
@@ -64,7 +64,7 @@ export function injectMcp(ns, wsPath, token) {
64
64
  return;
65
65
  }
66
66
  const npmCache = process.env.npm_config_cache ?? `${homedir()}/.npm`;
67
- const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "";
67
+ const trustedOwners = process.env.CC_AGENT_TRUSTED_OWNERS ?? "gonzih,ecoclaw,simorgh-app";
68
68
  const systemPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
69
69
  const config = {
70
70
  mcpServers: {
@@ -112,9 +112,21 @@ function resolveClaude() {
112
112
  }
113
113
  return "claude";
114
114
  }
115
+ /**
116
+ * Redis keys for meta-agent stdout streaming.
117
+ * cca:meta:{ns}:stream — pub/sub channel (live streaming)
118
+ * cca:meta:{ns}:log — list (history, capped at 2000)
119
+ */
120
+ export function metaStreamChannel(ns) {
121
+ return `cca:meta:${ns}:stream`;
122
+ }
123
+ export function metaLogKey(ns) {
124
+ return `cca:meta:${ns}:log`;
125
+ }
115
126
  /**
116
127
  * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
117
128
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
129
+ * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
118
130
  * Returns a Promise that resolves when the process exits.
119
131
  */
120
132
  export function spawnSession(ns, message, token, wire) {
@@ -134,6 +146,7 @@ export function spawnSession(ns, message, token, wire) {
134
146
  "--continue",
135
147
  "-p", message,
136
148
  "--output-format", "text",
149
+ "--verbose",
137
150
  "--dangerously-skip-permissions",
138
151
  ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
139
152
  let lineBuffer = "";
@@ -153,8 +166,27 @@ export function spawnSession(ns, message, token, wire) {
153
166
  console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
154
167
  });
155
168
  };
169
+ const rawRedis = wire._redis;
170
+ const streamToRedis = (chunk) => {
171
+ const entry = JSON.stringify({ ts: Date.now(), ns, text: chunk });
172
+ const streamCh = metaStreamChannel(ns);
173
+ const logKey = metaLogKey(ns);
174
+ // Publish for live consumers (fire-and-forget)
175
+ rawRedis.publish(streamCh, entry).catch((err) => {
176
+ console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
177
+ });
178
+ // Persist to log list, cap at 2000
179
+ rawRedis.lpush(logKey, entry).then(() => {
180
+ rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
181
+ }).catch((err) => {
182
+ console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
183
+ });
184
+ };
156
185
  proc.stdout.on("data", (chunk) => {
157
- lineBuffer += chunk.toString();
186
+ const chunkStr = chunk.toString();
187
+ // Stream raw chunk to Redis (before line-splitting)
188
+ streamToRedis(chunkStr);
189
+ lineBuffer += chunkStr;
158
190
  const lines = lineBuffer.split("\n");
159
191
  lineBuffer = lines.pop() ?? "";
160
192
  for (const line of lines) {
@@ -194,9 +226,10 @@ export function createMetaAgentManager() {
194
226
  const wsPath = workspacePath(ns);
195
227
  injectMcp(ns, wsPath, token);
196
228
  },
197
- startPolling(wire, getNamespaces) {
229
+ startPolling(wire, getNamespaces, instanceId) {
198
230
  if (pollInterval)
199
231
  return; // already running
232
+ const INSTANCE_KEY = "cca:discord:instance";
200
233
  pollInterval = setInterval(() => {
201
234
  const namespaces = getNamespaces();
202
235
  if (namespaces.length === 0)
@@ -208,6 +241,19 @@ export function createMetaAgentManager() {
208
241
  .then(async (msg) => {
209
242
  if (!msg)
210
243
  return;
244
+ // Staleness check: if a newer instance has registered, this process is stale — exit.
245
+ if (instanceId) {
246
+ try {
247
+ const current = await wire._redis.get(INSTANCE_KEY);
248
+ if (current && current !== instanceId) {
249
+ console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
250
+ process.exit(0);
251
+ }
252
+ }
253
+ catch {
254
+ // Redis error — proceed anyway
255
+ }
256
+ }
211
257
  const content = typeof msg === "string" ? msg : msg.content ?? String(msg);
212
258
  activeNamespaces.add(ns);
213
259
  await wire.discord.setStatus(ns, {
package/dist/notifier.js CHANGED
@@ -10,9 +10,53 @@
10
10
  * cca:discord:chat:log:{ns} — LPUSH + LTRIM 0 499 (last 500 messages)
11
11
  * cca:discord:chat:outgoing:{ns} — PUBLISH for web UI to consume
12
12
  */
13
+ import { createHash } from "crypto";
13
14
  import { discordChatLog, discordChatOutgoing, discordNotify, notifyChannel, chatIncomingChannel, createCcWire, TIMING, } from "@gonzih/cc-wire";
14
15
  import { splitLongMessage, stripAnsi } from "./formatter.js";
15
16
  import { parseEvalReport } from "./loop-manager.js";
17
+ /** Redis key for per-namespace notification dedup set */
18
+ function dedupKey(ns) {
19
+ return `cca:discord:sent:${ns}`;
20
+ }
21
+ /** Compute a short stable dedup fingerprint for a raw notification string */
22
+ function notifFingerprint(raw) {
23
+ return createHash("sha256").update(raw.slice(0, 500)).digest("hex").slice(0, 16);
24
+ }
25
+ /**
26
+ * Check whether this notification has already been forwarded for `ns` (Redis-backed dedup).
27
+ * If not, records it (SADD + EXPIRE 120s) and returns false (not a dup).
28
+ * Returns true if it's a duplicate and should be skipped.
29
+ */
30
+ async function checkAndMarkSent(redis, ns, raw) {
31
+ const key = dedupKey(ns);
32
+ const fp = notifFingerprint(raw);
33
+ // SADD returns 1 if the element was added (new), 0 if already existed (dup)
34
+ const added = await redis.sadd(key, fp);
35
+ if (added === 0)
36
+ return true; // duplicate
37
+ // Set/refresh the TTL on the set key
38
+ await redis.expire(key, 120);
39
+ return false;
40
+ }
41
+ /**
42
+ * In-memory dedup cache for pub/sub notifications (avoids async overhead on hot path).
43
+ * Maps fingerprint → expiry timestamp. Entries expire after 120 seconds.
44
+ */
45
+ const inMemoryDedupCache = new Map();
46
+ const IN_MEMORY_DEDUP_TTL_MS = 120_000;
47
+ function checkAndMarkSentSync(ns, raw) {
48
+ const fp = `${ns}:${notifFingerprint(raw)}`;
49
+ const now = Date.now();
50
+ // Evict expired entries (keep cache small)
51
+ for (const [k, exp] of inMemoryDedupCache) {
52
+ if (now > exp)
53
+ inMemoryDedupCache.delete(k);
54
+ }
55
+ if (inMemoryDedupCache.has(fp))
56
+ return true; // duplicate
57
+ inMemoryDedupCache.set(fp, now + IN_MEMORY_DEDUP_TTL_MS);
58
+ return false;
59
+ }
16
60
  function log(level, ...args) {
17
61
  const fn = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
18
62
  fn("[notifier]", ...args);
@@ -292,6 +336,25 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
292
336
  const notification = parseNotification(raw);
293
337
  if (notification === null)
294
338
  continue; // routing excludes discord
339
+ // Dedup: skip if this notification was already forwarded recently
340
+ // Uses Redis when available; falls back to in-memory dedup on Redis errors.
341
+ let isDup = false;
342
+ try {
343
+ if (typeof redis.sadd === "function") {
344
+ isDup = await checkAndMarkSent(redis, ns, raw);
345
+ }
346
+ else {
347
+ isDup = checkAndMarkSentSync(ns, raw);
348
+ }
349
+ }
350
+ catch (err) {
351
+ log("warn", `dedup check failed (ns=${ns}), falling back to in-memory:`, err.message);
352
+ isDup = checkAndMarkSentSync(ns, raw);
353
+ }
354
+ if (isDup) {
355
+ log("info", `dedup: skipping already-sent notification (ns=${ns})`);
356
+ continue;
357
+ }
295
358
  // Primary namespace: honour chatId-based per-channel routing via reverseSnowflakeLookup,
296
359
  // then namespace → channelId lookup, then notifyChannelId / active channel.
297
360
  // Routed namespaces: always deliver to the registered Discord channelId — no leakage.
@@ -348,6 +411,11 @@ export function startNotifier(bot, notifyChannelId, namespace, redis, handleUser
348
411
  const notification = parseNotification(message);
349
412
  if (notification === null)
350
413
  return; // routing excludes discord
414
+ // Synchronous in-memory dedup — keeps pub/sub handler synchronous
415
+ if (checkAndMarkSentSync(ns, message)) {
416
+ log("info", `dedup: skipping already-sent pub/sub notification (ns=${ns})`);
417
+ return;
418
+ }
351
419
  let mainChannelId;
352
420
  if (isPrimary) {
353
421
  mainChannelId = resolveNotifyChannel(notification.chatId, notifyChannelId, getActiveChannelId, reverseSnowflakeLookup, ns, getChannelIdForNamespace);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {