@gonzih/cc-discord 0.2.17 → 0.2.18

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
@@ -36,6 +36,7 @@ export declare class CcDiscordBot {
36
36
  private namespace;
37
37
  private lastActiveChannelId?;
38
38
  private cron;
39
+ private cronEngine?;
39
40
  private metaAgentManager;
40
41
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
41
42
  private mcpSession?;
@@ -90,6 +91,11 @@ export declare class CcDiscordBot {
90
91
  private registerSlashCommands;
91
92
  private handleSlashCommand;
92
93
  private handleCronsCommand;
94
+ /**
95
+ * Handle the /cron command group — backed by CronEngine (Redis-persisted, node-cron scheduled).
96
+ * Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
97
+ */
98
+ private handleCronEngineCommand;
93
99
  /**
94
100
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
95
101
  * Returns the tool result as a string, or null on failure.
@@ -140,6 +146,11 @@ export declare class CcDiscordBot {
140
146
  * Passes a live getter for the set of registered namespaces.
141
147
  */
142
148
  startMetaAgentPolling(): void;
149
+ /**
150
+ * Start the CronEngine — loads all enabled crons from Redis and schedules them.
151
+ * Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
152
+ */
153
+ startCronEngine(): void;
143
154
  /**
144
155
  * Resolve the namespace for a given channelId.
145
156
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
package/dist/bot.js CHANGED
@@ -16,6 +16,7 @@ import { getCurrentToken } from "./tokens.js";
16
16
  import { writeChatLog } from "./notifier.js";
17
17
  import { LoopManager, isGoalMessage } from "./loop-manager.js";
18
18
  import { CronManager } from "./cron.js";
19
+ import { CronEngine } from "./cron-engine.js";
19
20
  import { parseChannelCreateIntent, routeToMetaAgent } from "./router.js";
20
21
  import { createMetaAgentManager } from "./meta-agent-manager.js";
21
22
  /** Convert a Discord snowflake string to a safe 53-bit integer for CronManager compatibility. */
@@ -121,6 +122,7 @@ export class CcDiscordBot {
121
122
  namespace;
122
123
  lastActiveChannelId;
123
124
  cron;
125
+ cronEngine;
124
126
  metaAgentManager;
125
127
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
126
128
  mcpSession;
@@ -132,6 +134,7 @@ export class CcDiscordBot {
132
134
  if (opts.redis) {
133
135
  this.wire = createCcWire(opts.redis);
134
136
  this.loopManager = new LoopManager(opts.redis);
137
+ this.cronEngine = new CronEngine(opts.redis);
135
138
  }
136
139
  this.metaAgentManager = createMetaAgentManager();
137
140
  this.client = new Client({
@@ -826,6 +829,28 @@ export class CcDiscordBot {
826
829
  new SlashCommandBuilder()
827
830
  .setName("compact")
828
831
  .setDescription("Compact the Claude session context for this channel's namespace"),
832
+ new SlashCommandBuilder()
833
+ .setName("cron")
834
+ .setDescription("Manage Redis-persisted cron jobs for this channel's namespace")
835
+ .addSubcommand((sub) => sub
836
+ .setName("add")
837
+ .setDescription("Add a cron job (standard 5-field cron expression)")
838
+ .addStringOption((opt) => opt.setName("schedule").setDescription("Cron expression e.g. '0 * * * *'").setRequired(true))
839
+ .addStringOption((opt) => opt.setName("message").setDescription("Message to push to the meta-agent input queue").setRequired(true))
840
+ .addIntegerOption((opt) => opt.setName("compact_every").setDescription("Push /compact every N fires (default: 10, 0 = never)").setRequired(false)))
841
+ .addSubcommand((sub) => sub.setName("list").setDescription("List all cron jobs"))
842
+ .addSubcommand((sub) => sub
843
+ .setName("pause")
844
+ .setDescription("Pause a cron job")
845
+ .addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true)))
846
+ .addSubcommand((sub) => sub
847
+ .setName("resume")
848
+ .setDescription("Resume a paused cron job")
849
+ .addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true)))
850
+ .addSubcommand((sub) => sub
851
+ .setName("delete")
852
+ .setDescription("Delete a cron job permanently")
853
+ .addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true))),
829
854
  ].map((cmd) => cmd.toJSON());
830
855
  const rest = new REST().setToken(this.opts.discordToken);
831
856
  if (this.opts.guildIds?.length) {
@@ -972,6 +997,10 @@ export class CcDiscordBot {
972
997
  });
973
998
  break;
974
999
  }
1000
+ case "cron": {
1001
+ await this.handleCronEngineCommand(interaction, channelId);
1002
+ break;
1003
+ }
975
1004
  }
976
1005
  }
977
1006
  async handleCronsCommand(interaction, channelId) {
@@ -1020,6 +1049,79 @@ export class CcDiscordBot {
1020
1049
  await interaction.reply("Unknown subcommand.");
1021
1050
  }
1022
1051
  }
1052
+ /**
1053
+ * Handle the /cron command group — backed by CronEngine (Redis-persisted, node-cron scheduled).
1054
+ * Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
1055
+ */
1056
+ async handleCronEngineCommand(interaction, channelId) {
1057
+ if (!this.cronEngine) {
1058
+ await interaction.reply({ content: "Cron engine unavailable (no Redis connection).", ephemeral: true });
1059
+ return;
1060
+ }
1061
+ const ns = this.resolveNamespaceForChannel(channelId);
1062
+ const sub = interaction.options.getSubcommand();
1063
+ switch (sub) {
1064
+ case "add": {
1065
+ const cronSchedule = interaction.options.getString("schedule", true);
1066
+ const message = interaction.options.getString("message", true);
1067
+ const compactEvery = interaction.options.getInteger("compact_every") ?? 10;
1068
+ await interaction.deferReply();
1069
+ const rec = await this.cronEngine.add(ns, cronSchedule, message, compactEvery);
1070
+ if (!rec) {
1071
+ await interaction.editReply(`Invalid cron expression: \`${cronSchedule}\`\nUse standard 5-field format, e.g. \`0 * * * *\` (hourly) or \`*/30 * * * *\` (every 30 min).`);
1072
+ }
1073
+ else {
1074
+ await interaction.editReply(`Cron added for namespace **${ns}**\nID: \`${rec.id}\`\nSchedule: \`${rec.schedule}\`\nCompact every: ${rec.compact_every} fires`);
1075
+ }
1076
+ break;
1077
+ }
1078
+ case "list": {
1079
+ await interaction.deferReply();
1080
+ const allCrons = await this.cronEngine.list();
1081
+ const nsCrons = allCrons.filter((r) => r.namespace === ns);
1082
+ if (nsCrons.length === 0) {
1083
+ await interaction.editReply(`No cron jobs for namespace **${ns}**.`);
1084
+ }
1085
+ else {
1086
+ const embed = new EmbedBuilder()
1087
+ .setTitle(`Cron jobs — ${ns}`)
1088
+ .setColor(0x5865F2)
1089
+ .setDescription(nsCrons
1090
+ .map((r) => {
1091
+ const status = r.enabled ? "enabled" : "paused";
1092
+ const last = r.last_fired_at ? `last fired ${r.last_fired_at}` : "never fired";
1093
+ return `**\`${r.id}\`** [${status}]\nSchedule: \`${r.schedule}\` | Fires: ${r.fire_count} | ${last}\nMessage: \`${r.message.slice(0, 80)}${r.message.length > 80 ? "…" : ""}\``;
1094
+ })
1095
+ .join("\n\n"));
1096
+ await interaction.editReply({ embeds: [embed] });
1097
+ }
1098
+ break;
1099
+ }
1100
+ case "pause": {
1101
+ const id = interaction.options.getString("id", true);
1102
+ await interaction.deferReply();
1103
+ const ok = await this.cronEngine.pause(id);
1104
+ await interaction.editReply(ok ? `Cron \`${id}\` paused.` : `Cron \`${id}\` not found.`);
1105
+ break;
1106
+ }
1107
+ case "resume": {
1108
+ const id = interaction.options.getString("id", true);
1109
+ await interaction.deferReply();
1110
+ const ok = await this.cronEngine.resume(id);
1111
+ await interaction.editReply(ok ? `Cron \`${id}\` resumed.` : `Cron \`${id}\` not found.`);
1112
+ break;
1113
+ }
1114
+ case "delete": {
1115
+ const id = interaction.options.getString("id", true);
1116
+ await interaction.deferReply();
1117
+ const ok = await this.cronEngine.delete(id);
1118
+ await interaction.editReply(ok ? `Cron \`${id}\` deleted.` : `Cron \`${id}\` not found.`);
1119
+ break;
1120
+ }
1121
+ default:
1122
+ await interaction.reply("Unknown /cron subcommand.");
1123
+ }
1124
+ }
1023
1125
  /**
1024
1126
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
1025
1127
  * Returns the tool result as a string, or null on failure.
@@ -1326,6 +1428,17 @@ export class CcDiscordBot {
1326
1428
  return;
1327
1429
  this.metaAgentManager.startPolling(this.wire, () => Array.from(this.channelNamespaceMap.values()), this.opts.instanceId);
1328
1430
  }
1431
+ /**
1432
+ * Start the CronEngine — loads all enabled crons from Redis and schedules them.
1433
+ * Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
1434
+ */
1435
+ startCronEngine() {
1436
+ if (!this.cronEngine)
1437
+ return;
1438
+ this.cronEngine.start().catch((err) => {
1439
+ console.warn("[bot] cronEngine.start() failed:", err.message);
1440
+ });
1441
+ }
1329
1442
  /**
1330
1443
  * Resolve the namespace for a given channelId.
1331
1444
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * CronEngine — Redis-persisted, node-cron–scheduled meta-agent messaging.
3
+ *
4
+ * Redis key schema:
5
+ * cca:discord:cron:list — SET of cron IDs
6
+ * cca:discord:cron:{id} — HASH with fields:
7
+ * namespace, schedule, message, enabled,
8
+ * fire_count, compact_every,
9
+ * created_at, last_fired_at
10
+ *
11
+ * On each fire:
12
+ * 1. Increment fire_count
13
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
14
+ * — skip if the exact message is already queued
15
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
16
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
17
+ * 5. Update last_fired_at
18
+ */
19
+ import type { Redis } from "ioredis";
20
+ export interface CronRecord {
21
+ id: string;
22
+ namespace: string;
23
+ schedule: string;
24
+ message: string;
25
+ enabled: boolean;
26
+ fire_count: number;
27
+ compact_every: number;
28
+ created_at: string;
29
+ last_fired_at: string;
30
+ }
31
+ export declare class CronEngine {
32
+ private redis;
33
+ /** Map of cron ID → active node-cron ScheduledTask */
34
+ private tasks;
35
+ constructor(redis: Redis);
36
+ /**
37
+ * Add a new cron.
38
+ * @param namespace The meta-agent namespace to push messages to.
39
+ * @param cronSchedule A standard 5-field cron expression (e.g. "0 * * * *").
40
+ * @param message Text content to push into the meta-agent input queue.
41
+ * @param compact_every Push /compact every N fires. Defaults to 10.
42
+ * @returns The created CronRecord, or null if the schedule is invalid.
43
+ */
44
+ add(namespace: string, cronSchedule: string, message: string, compact_every?: number): Promise<CronRecord | null>;
45
+ /**
46
+ * List all crons stored in Redis.
47
+ */
48
+ list(): Promise<CronRecord[]>;
49
+ /**
50
+ * Pause a cron (sets enabled=false and stops the node-cron task).
51
+ */
52
+ pause(id: string): Promise<boolean>;
53
+ /**
54
+ * Resume a paused cron (sets enabled=true and re-schedules the task).
55
+ */
56
+ resume(id: string): Promise<boolean>;
57
+ /**
58
+ * Delete a cron (stops the task and removes from Redis).
59
+ */
60
+ delete(id: string): Promise<boolean>;
61
+ /**
62
+ * Load all enabled crons from Redis and schedule them.
63
+ * Call once after the bot connects and Redis is ready.
64
+ */
65
+ start(): Promise<void>;
66
+ private scheduleTask;
67
+ /**
68
+ * Execute one cron tick:
69
+ * 1. Reload record (picks up any pause between schedule and fire)
70
+ * 2. Increment fire_count
71
+ * 3. Duplicate check against the input queue
72
+ * 4. Optionally push /compact
73
+ * 5. Push the message
74
+ * 6. Update last_fired_at
75
+ */
76
+ private fire;
77
+ /** Write a CronRecord to Redis as a HASH. */
78
+ private saveRecord;
79
+ /** Load a CronRecord from Redis. Returns null if the key doesn't exist. */
80
+ private loadRecord;
81
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * CronEngine — Redis-persisted, node-cron–scheduled meta-agent messaging.
3
+ *
4
+ * Redis key schema:
5
+ * cca:discord:cron:list — SET of cron IDs
6
+ * cca:discord:cron:{id} — HASH with fields:
7
+ * namespace, schedule, message, enabled,
8
+ * fire_count, compact_every,
9
+ * created_at, last_fired_at
10
+ *
11
+ * On each fire:
12
+ * 1. Increment fire_count
13
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
14
+ * — skip if the exact message is already queued
15
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
16
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
17
+ * 5. Update last_fired_at
18
+ */
19
+ import { randomUUID } from "crypto";
20
+ import { schedule, validate } from "node-cron";
21
+ // ─── Redis key helpers ────────────────────────────────────────────────────────
22
+ const CRON_LIST_KEY = "cca:discord:cron:list";
23
+ const cronHashKey = (id) => `cca:discord:cron:${id}`;
24
+ const metaInputKey = (ns) => `cca:discord:meta:${ns}:input`;
25
+ // ─── CronEngine ──────────────────────────────────────────────────────────────
26
+ export class CronEngine {
27
+ redis;
28
+ /** Map of cron ID → active node-cron ScheduledTask */
29
+ tasks = new Map();
30
+ constructor(redis) {
31
+ this.redis = redis;
32
+ }
33
+ /**
34
+ * Add a new cron.
35
+ * @param namespace The meta-agent namespace to push messages to.
36
+ * @param cronSchedule A standard 5-field cron expression (e.g. "0 * * * *").
37
+ * @param message Text content to push into the meta-agent input queue.
38
+ * @param compact_every Push /compact every N fires. Defaults to 10.
39
+ * @returns The created CronRecord, or null if the schedule is invalid.
40
+ */
41
+ async add(namespace, cronSchedule, message, compact_every = 10) {
42
+ if (!validate(cronSchedule))
43
+ return null;
44
+ const id = randomUUID();
45
+ const now = new Date().toISOString();
46
+ const record = {
47
+ id,
48
+ namespace,
49
+ schedule: cronSchedule,
50
+ message,
51
+ enabled: true,
52
+ fire_count: 0,
53
+ compact_every,
54
+ created_at: now,
55
+ last_fired_at: "",
56
+ };
57
+ await this.saveRecord(record);
58
+ await this.redis.sadd(CRON_LIST_KEY, id);
59
+ this.scheduleTask(record);
60
+ console.log(`[cron-engine] added cron id=${id} ns=${namespace} schedule="${cronSchedule}"`);
61
+ return record;
62
+ }
63
+ /**
64
+ * List all crons stored in Redis.
65
+ */
66
+ async list() {
67
+ const ids = await this.redis.smembers(CRON_LIST_KEY);
68
+ const records = [];
69
+ for (const id of ids) {
70
+ const rec = await this.loadRecord(id);
71
+ if (rec)
72
+ records.push(rec);
73
+ }
74
+ return records;
75
+ }
76
+ /**
77
+ * Pause a cron (sets enabled=false and stops the node-cron task).
78
+ */
79
+ async pause(id) {
80
+ const rec = await this.loadRecord(id);
81
+ if (!rec)
82
+ return false;
83
+ rec.enabled = false;
84
+ await this.saveRecord(rec);
85
+ const task = this.tasks.get(id);
86
+ if (task) {
87
+ await task.stop();
88
+ }
89
+ console.log(`[cron-engine] paused cron id=${id}`);
90
+ return true;
91
+ }
92
+ /**
93
+ * Resume a paused cron (sets enabled=true and re-schedules the task).
94
+ */
95
+ async resume(id) {
96
+ const rec = await this.loadRecord(id);
97
+ if (!rec)
98
+ return false;
99
+ rec.enabled = true;
100
+ await this.saveRecord(rec);
101
+ // Re-schedule if not already running
102
+ if (!this.tasks.has(id)) {
103
+ this.scheduleTask(rec);
104
+ }
105
+ else {
106
+ const task = this.tasks.get(id);
107
+ await task.start();
108
+ }
109
+ console.log(`[cron-engine] resumed cron id=${id}`);
110
+ return true;
111
+ }
112
+ /**
113
+ * Delete a cron (stops the task and removes from Redis).
114
+ */
115
+ async delete(id) {
116
+ const rec = await this.loadRecord(id);
117
+ if (!rec)
118
+ return false;
119
+ const task = this.tasks.get(id);
120
+ if (task) {
121
+ await task.stop();
122
+ await task.destroy();
123
+ this.tasks.delete(id);
124
+ }
125
+ await this.redis.srem(CRON_LIST_KEY, id);
126
+ await this.redis.del(cronHashKey(id));
127
+ console.log(`[cron-engine] deleted cron id=${id}`);
128
+ return true;
129
+ }
130
+ /**
131
+ * Load all enabled crons from Redis and schedule them.
132
+ * Call once after the bot connects and Redis is ready.
133
+ */
134
+ async start() {
135
+ const ids = await this.redis.smembers(CRON_LIST_KEY);
136
+ let scheduled = 0;
137
+ for (const id of ids) {
138
+ const rec = await this.loadRecord(id);
139
+ if (!rec)
140
+ continue;
141
+ if (rec.enabled) {
142
+ this.scheduleTask(rec);
143
+ scheduled++;
144
+ }
145
+ }
146
+ console.log(`[cron-engine] started — loaded ${ids.length} crons, scheduled ${scheduled}`);
147
+ }
148
+ // ─── Private helpers ────────────────────────────────────────────────────────
149
+ scheduleTask(rec) {
150
+ // Destroy any existing task for this ID
151
+ const existing = this.tasks.get(rec.id);
152
+ if (existing) {
153
+ void existing.stop();
154
+ void existing.destroy();
155
+ this.tasks.delete(rec.id);
156
+ }
157
+ const task = schedule(rec.schedule, async () => {
158
+ await this.fire(rec.id);
159
+ }, { noOverlap: true });
160
+ this.tasks.set(rec.id, task);
161
+ }
162
+ /**
163
+ * Execute one cron tick:
164
+ * 1. Reload record (picks up any pause between schedule and fire)
165
+ * 2. Increment fire_count
166
+ * 3. Duplicate check against the input queue
167
+ * 4. Optionally push /compact
168
+ * 5. Push the message
169
+ * 6. Update last_fired_at
170
+ */
171
+ async fire(id) {
172
+ const rec = await this.loadRecord(id);
173
+ if (!rec) {
174
+ console.warn(`[cron-engine] fire: record not found for id=${id}`);
175
+ return;
176
+ }
177
+ if (!rec.enabled) {
178
+ console.log(`[cron-engine] fire: cron id=${id} is disabled — skipping`);
179
+ return;
180
+ }
181
+ const ns = rec.namespace;
182
+ const inputKey = metaInputKey(ns);
183
+ // Increment fire_count
184
+ rec.fire_count += 1;
185
+ // Duplicate check: skip if the message is already queued
186
+ try {
187
+ const queued = await this.redis.lrange(inputKey, 0, -1);
188
+ const alreadyQueued = queued.some((entry) => {
189
+ try {
190
+ const parsed = JSON.parse(entry);
191
+ return parsed.content === rec.message;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ });
197
+ if (alreadyQueued) {
198
+ console.log(`[cron-engine] fire: duplicate detected, skipping push (id=${id} ns=${ns})`);
199
+ await this.saveRecord(rec);
200
+ return;
201
+ }
202
+ }
203
+ catch (err) {
204
+ console.warn(`[cron-engine] fire: duplicate check failed (id=${id}):`, err.message);
205
+ }
206
+ // Auto-compact every N fires
207
+ if (rec.compact_every > 0 && rec.fire_count % rec.compact_every === 0) {
208
+ const compactEntry = JSON.stringify({
209
+ id: randomUUID(),
210
+ content: "/compact",
211
+ timestamp: new Date().toISOString(),
212
+ source: "cron",
213
+ });
214
+ try {
215
+ await this.redis.rpush(inputKey, compactEntry);
216
+ console.log(`[cron-engine] fire: pushed /compact (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
217
+ }
218
+ catch (err) {
219
+ console.warn(`[cron-engine] fire: /compact push failed (id=${id}):`, err.message);
220
+ }
221
+ }
222
+ // Push the scheduled message
223
+ const entry = JSON.stringify({
224
+ id: randomUUID(),
225
+ content: rec.message,
226
+ timestamp: new Date().toISOString(),
227
+ source: "cron",
228
+ });
229
+ try {
230
+ await this.redis.rpush(inputKey, entry);
231
+ rec.last_fired_at = new Date().toISOString();
232
+ console.log(`[cron-engine] fire: pushed message (id=${id} ns=${ns} fire_count=${rec.fire_count})`);
233
+ }
234
+ catch (err) {
235
+ console.warn(`[cron-engine] fire: push failed (id=${id}):`, err.message);
236
+ }
237
+ await this.saveRecord(rec);
238
+ }
239
+ /** Write a CronRecord to Redis as a HASH. */
240
+ async saveRecord(rec) {
241
+ const key = cronHashKey(rec.id);
242
+ await this.redis.hset(key, {
243
+ id: rec.id,
244
+ namespace: rec.namespace,
245
+ schedule: rec.schedule,
246
+ message: rec.message,
247
+ enabled: rec.enabled ? "1" : "0",
248
+ fire_count: String(rec.fire_count),
249
+ compact_every: String(rec.compact_every),
250
+ created_at: rec.created_at,
251
+ last_fired_at: rec.last_fired_at,
252
+ });
253
+ }
254
+ /** Load a CronRecord from Redis. Returns null if the key doesn't exist. */
255
+ async loadRecord(id) {
256
+ const data = await this.redis.hgetall(cronHashKey(id));
257
+ if (!data || !data.id)
258
+ return null;
259
+ return {
260
+ id: data.id,
261
+ namespace: data.namespace,
262
+ schedule: data.schedule,
263
+ message: data.message,
264
+ enabled: data.enabled === "1",
265
+ fire_count: parseInt(data.fire_count ?? "0", 10),
266
+ compact_every: parseInt(data.compact_every ?? "10", 10),
267
+ created_at: data.created_at ?? "",
268
+ last_fired_at: data.last_fired_at ?? "",
269
+ };
270
+ }
271
+ }
package/dist/index.js CHANGED
@@ -144,6 +144,8 @@ async function runStartupMigrations() {
144
144
  console.log("[cc-discord] startup migrations complete");
145
145
  // 3. Start meta-agent polling now that data is migrated
146
146
  bot.startMetaAgentPolling();
147
+ // 4. Start cron engine — loads and schedules all Redis-persisted crons
148
+ bot.startCronEngine();
147
149
  }
148
150
  // Mutable placeholder closures — filled in once `bot` is created below
149
151
  let getLastActiveChannelIdFn = () => undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,8 +19,10 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "@gonzih/cc-wire": "^0.3.0",
22
+ "@types/node-cron": "^3.0.11",
22
23
  "discord.js": "^14.0.0",
23
- "ioredis": "^5.0.0"
24
+ "ioredis": "^5.0.0",
25
+ "node-cron": "^4.4.1"
24
26
  },
25
27
  "devDependencies": {
26
28
  "@types/node": "^22.0.0",