@gonzih/cc-discord 0.2.27 → 0.2.29

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 lastActiveChannelId?;
37
37
  private cron;
38
38
  private cronEngine?;
39
+ private loopEngine?;
39
40
  private metaAgentManager;
40
41
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
41
42
  private mcpSession?;
@@ -100,6 +101,12 @@ export declare class CcDiscordBot {
100
101
  * Namespace is derived from the channel's registered namespace (same pattern as /clear and /compact).
101
102
  */
102
103
  private handleCronEngineCommand;
104
+ /**
105
+ * Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
106
+ * Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
107
+ * Namespace is derived from the channel's registered namespace.
108
+ */
109
+ private handleLoopEngineCommand;
103
110
  /**
104
111
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
105
112
  * Returns the tool result as a string, or null on failure.
@@ -150,6 +157,12 @@ export declare class CcDiscordBot {
150
157
  * Called from index.ts after startup migrations complete (alongside startMetaAgentPolling).
151
158
  */
152
159
  startCronEngine(): void;
160
+ /**
161
+ * Start the LoopEngine — loads all active loops from Redis, fires each immediately,
162
+ * then starts their repeat timers.
163
+ * Called from index.ts after startup migrations complete (alongside startCronEngine).
164
+ */
165
+ startLoopEngine(): void;
153
166
  /**
154
167
  * Resolve the namespace for a given channelId.
155
168
  * 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 { CronManager } from "./cron.js";
18
18
  import { CronEngine, cronPendingKey, CRON_MESSAGE_TTL } from "./cron-engine.js";
19
+ import { LoopEngine, parseIntervalMs } from "./loop-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. */
@@ -122,6 +123,7 @@ export class CcDiscordBot {
122
123
  lastActiveChannelId;
123
124
  cron;
124
125
  cronEngine;
126
+ loopEngine;
125
127
  metaAgentManager;
126
128
  /** ClaudeProcess running the MCP tool bridge (for callCcAgentTool) */
127
129
  mcpSession;
@@ -132,6 +134,7 @@ export class CcDiscordBot {
132
134
  if (opts.redis) {
133
135
  this.wire = createCcWire(opts.redis);
134
136
  this.cronEngine = new CronEngine(opts.redis);
137
+ this.loopEngine = new LoopEngine(opts.redis);
135
138
  }
136
139
  this.metaAgentManager = createMetaAgentManager();
137
140
  this.client = new Client({
@@ -842,6 +845,28 @@ export class CcDiscordBot {
842
845
  .setName("delete")
843
846
  .setDescription("Delete a cron job permanently")
844
847
  .addStringOption((opt) => opt.setName("id").setDescription("Cron ID").setRequired(true))),
848
+ new SlashCommandBuilder()
849
+ .setName("loop")
850
+ .setDescription("Manage Redis-persisted interval loops for this channel's namespace")
851
+ .addSubcommand((sub) => sub
852
+ .setName("add")
853
+ .setDescription("Add a loop (fires immediately, then repeats at fixed interval)")
854
+ .addStringOption((opt) => opt.setName("interval").setDescription("Interval e.g. '30m', '1h', '20m', '30s'").setRequired(true))
855
+ .addStringOption((opt) => opt.setName("message").setDescription("Message to push to the meta-agent input queue").setRequired(true))
856
+ .addIntegerOption((opt) => opt.setName("compact_every").setDescription("Push /compact every N fires (default: 10, 0 = never)").setRequired(false)))
857
+ .addSubcommand((sub) => sub.setName("list").setDescription("List all loops for this namespace"))
858
+ .addSubcommand((sub) => sub
859
+ .setName("pause")
860
+ .setDescription("Pause a loop")
861
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
862
+ .addSubcommand((sub) => sub
863
+ .setName("resume")
864
+ .setDescription("Resume a paused loop (fires immediately)")
865
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true)))
866
+ .addSubcommand((sub) => sub
867
+ .setName("delete")
868
+ .setDescription("Delete a loop permanently")
869
+ .addStringOption((opt) => opt.setName("id").setDescription("Loop ID").setRequired(true))),
845
870
  ].map((cmd) => cmd.toJSON());
846
871
  const rest = new REST().setToken(this.opts.discordToken);
847
872
  if (this.opts.guildIds?.length) {
@@ -974,6 +999,8 @@ export class CcDiscordBot {
974
999
  }
975
1000
  case "clear": {
976
1001
  const ns = this.resolveNamespaceForChannel(channelId);
1002
+ // Kill the persistent session first so Claude releases the JSONL file
1003
+ this.metaAgentManager.killSession(ns);
977
1004
  const deleted = this.clearClaudeSession(ns);
978
1005
  await interaction.reply(deleted > 0
979
1006
  ? `Context cleared for ${ns} (${deleted} session file${deleted === 1 ? "" : "s"} removed). Next message starts fresh.`
@@ -983,15 +1010,18 @@ export class CcDiscordBot {
983
1010
  case "compact": {
984
1011
  const ns = this.resolveNamespaceForChannel(channelId);
985
1012
  await interaction.reply(`Compacting context for ${ns}...`);
986
- this.compactClaudeSession(ns).catch((err) => {
987
- console.warn(`[bot] /compact failed (ns=${ns}):`, err.message);
988
- });
1013
+ // Send /compact via stdin to the running persistent session
1014
+ this.metaAgentManager.sendToSession(ns, "/compact");
989
1015
  break;
990
1016
  }
991
1017
  case "cron": {
992
1018
  await this.handleCronEngineCommand(interaction, channelId);
993
1019
  break;
994
1020
  }
1021
+ case "loop": {
1022
+ await this.handleLoopEngineCommand(interaction, channelId);
1023
+ break;
1024
+ }
995
1025
  }
996
1026
  }
997
1027
  async handleCronsCommand(interaction, channelId) {
@@ -1113,6 +1143,82 @@ export class CcDiscordBot {
1113
1143
  await interaction.reply("Unknown /cron subcommand.");
1114
1144
  }
1115
1145
  }
1146
+ /**
1147
+ * Handle the /loop command group — backed by LoopEngine (Redis-persisted, interval-based).
1148
+ * Loops fire immediately on creation and on startup, then repeat every intervalMs ms.
1149
+ * Namespace is derived from the channel's registered namespace.
1150
+ */
1151
+ async handleLoopEngineCommand(interaction, channelId) {
1152
+ if (!this.loopEngine) {
1153
+ await interaction.reply({ content: "Loop engine unavailable (no Redis connection).", ephemeral: true });
1154
+ return;
1155
+ }
1156
+ const ns = this.resolveNamespaceForChannel(channelId);
1157
+ const sub = interaction.options.getSubcommand();
1158
+ switch (sub) {
1159
+ case "add": {
1160
+ const intervalStr = interaction.options.getString("interval", true);
1161
+ const message = interaction.options.getString("message", true);
1162
+ const compactEvery = interaction.options.getInteger("compact_every") ?? 10;
1163
+ const intervalMs = parseIntervalMs(intervalStr);
1164
+ if (intervalMs === null || intervalMs <= 0) {
1165
+ await interaction.reply(`Invalid interval: \`${intervalStr}\`\nUse format: \`30s\`, \`5m\`, \`20m\`, \`1h\`, \`2h\`, \`1d\``);
1166
+ return;
1167
+ }
1168
+ await interaction.deferReply();
1169
+ const rec = await this.loopEngine.add(ns, intervalMs, message, compactEvery);
1170
+ const humanInterval = intervalStr;
1171
+ await interaction.editReply(`Loop added for namespace **${ns}**\nID: \`${rec.id}\`\nInterval: \`${humanInterval}\` (${intervalMs}ms)\nCompact every: ${rec.compactEvery} fires\n(Fired immediately on creation.)`);
1172
+ break;
1173
+ }
1174
+ case "list": {
1175
+ await interaction.deferReply();
1176
+ const allLoops = await this.loopEngine.list();
1177
+ const nsLoops = allLoops.filter((r) => r.namespace === ns);
1178
+ if (nsLoops.length === 0) {
1179
+ await interaction.editReply(`No loops for namespace **${ns}**.`);
1180
+ }
1181
+ else {
1182
+ const embed = new EmbedBuilder()
1183
+ .setTitle(`Loops — ${ns}`)
1184
+ .setColor(0x57F287)
1185
+ .setDescription(nsLoops
1186
+ .map((r) => {
1187
+ const statusEmoji = r.status === "active" ? "🟢" : "⏸️";
1188
+ const last = r.lastRun ? `last run ${r.lastRun}` : "never run";
1189
+ const intervalSec = Math.round(r.intervalMs / 1000);
1190
+ return `${statusEmoji} **\`${r.id}\`** [${r.status}]\nInterval: ${intervalSec}s | Fires: ${r.fireCount} | ${last}\nMessage: \`${r.message.slice(0, 80)}${r.message.length > 80 ? "…" : ""}\``;
1191
+ })
1192
+ .join("\n\n"));
1193
+ await interaction.editReply({ embeds: [embed] });
1194
+ }
1195
+ break;
1196
+ }
1197
+ case "pause": {
1198
+ const id = interaction.options.getString("id", true);
1199
+ await interaction.deferReply();
1200
+ const ok = await this.loopEngine.pause(id);
1201
+ await interaction.editReply(ok ? `Loop \`${id}\` paused.` : `Loop \`${id}\` not found.`);
1202
+ break;
1203
+ }
1204
+ case "resume": {
1205
+ const id = interaction.options.getString("id", true);
1206
+ await interaction.deferReply();
1207
+ const ok = await this.loopEngine.resume(id);
1208
+ await interaction.editReply(ok ? `Loop \`${id}\` resumed (fired immediately).` : `Loop \`${id}\` not found.`);
1209
+ break;
1210
+ }
1211
+ case "delete": {
1212
+ const id = interaction.options.getString("id", true);
1213
+ await interaction.deferReply();
1214
+ const ok = await this.loopEngine.delete(id);
1215
+ await interaction.editReply(ok ? `Loop \`${id}\` deleted.` : `Loop \`${id}\` not found.`);
1216
+ break;
1217
+ }
1218
+ default:
1219
+ await interaction.reply("Unknown /loop subcommand.");
1220
+ }
1221
+ }
1116
1222
  /**
1117
1223
  * Call a cc-agent MCP tool via a dedicated ClaudeProcess.
1118
1224
  * Returns the tool result as a string, or null on failure.
@@ -1356,6 +1462,18 @@ export class CcDiscordBot {
1356
1462
  console.warn("[bot] cronEngine.start() failed:", err.message);
1357
1463
  });
1358
1464
  }
1465
+ /**
1466
+ * Start the LoopEngine — loads all active loops from Redis, fires each immediately,
1467
+ * then starts their repeat timers.
1468
+ * Called from index.ts after startup migrations complete (alongside startCronEngine).
1469
+ */
1470
+ startLoopEngine() {
1471
+ if (!this.loopEngine)
1472
+ return;
1473
+ this.loopEngine.start().catch((err) => {
1474
+ console.warn("[bot] loopEngine.start() failed:", err.message);
1475
+ });
1476
+ }
1359
1477
  /**
1360
1478
  * Resolve the namespace for a given channelId.
1361
1479
  * Routed channels use their registered namespace; everything else uses the bot's primary namespace.
@@ -1452,6 +1570,7 @@ export class CcDiscordBot {
1452
1570
  this.stopMetaAgentTyping(channelId);
1453
1571
  }
1454
1572
  this.metaAgentManager.stop();
1573
+ this.loopEngine?.stop();
1455
1574
  void this.client.destroy();
1456
1575
  }
1457
1576
  }
package/dist/index.js CHANGED
@@ -145,6 +145,8 @@ async function runStartupMigrations() {
145
145
  bot.startMetaAgentPolling();
146
146
  // 4. Start cron engine — loads and schedules all Redis-persisted crons
147
147
  bot.startCronEngine();
148
+ // 5. Start loop engine — loads all active loops, fires each immediately, then starts timers
149
+ bot.startLoopEngine();
148
150
  }
149
151
  // Mutable placeholder closures — filled in once `bot` is created below
150
152
  let getLastActiveChannelIdFn = () => undefined;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * LoopEngine — Redis-persisted, interval-based meta-agent messaging.
3
+ *
4
+ * Loops differ from crons:
5
+ * - Fire IMMEDIATELY on creation and on startup
6
+ * - Repeat at a fixed millisecond interval (not a cron expression)
7
+ *
8
+ * Redis key schema:
9
+ * cca:discord:loop:list — SET of loop IDs
10
+ * cca:discord:loop:{id} — HASH with fields:
11
+ * id, namespace, message, interval_ms,
12
+ * fire_count, compact_every, status,
13
+ * last_run, created_at
14
+ *
15
+ * On each fire:
16
+ * 1. Increment fire_count
17
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
18
+ * — skip if the exact message is already queued
19
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
20
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
21
+ * 5. Update last_run
22
+ */
23
+ import type { Redis } from "ioredis";
24
+ /**
25
+ * Temporary key written by the loop engine when it fires a loop.
26
+ * Value is the loopId that fired. TTL 300s.
27
+ * The bot reads this after sending a Discord message to link the message to the loop.
28
+ */
29
+ export declare const loopPendingKey: (ns: string) => string;
30
+ /**
31
+ * Key that maps a sent Discord messageId → loopId.
32
+ * Used by the reaction handler to look up which loop to disable.
33
+ * TTL 86400s (24h).
34
+ */
35
+ export declare const LOOP_MESSAGE_TTL = 86400;
36
+ /**
37
+ * Parse a human-readable interval string to milliseconds.
38
+ * Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
39
+ * Returns null if the format is unrecognised.
40
+ */
41
+ export declare function parseIntervalMs(input: string): number | null;
42
+ export interface LoopRecord {
43
+ id: string;
44
+ namespace: string;
45
+ message: string;
46
+ intervalMs: number;
47
+ fireCount: number;
48
+ compactEvery: number;
49
+ status: "active" | "paused";
50
+ lastRun: string;
51
+ createdAt: string;
52
+ }
53
+ export declare class LoopEngine {
54
+ private redis;
55
+ /** Map of loop ID → NodeJS.Timeout handle */
56
+ private timers;
57
+ constructor(redis: Redis);
58
+ /**
59
+ * Add a new loop.
60
+ * Fires immediately, then repeats every intervalMs milliseconds.
61
+ *
62
+ * @param namespace The meta-agent namespace to push messages to.
63
+ * @param intervalMs Milliseconds between fires.
64
+ * @param message Text content to push into the meta-agent input queue.
65
+ * @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
66
+ * @returns The created LoopRecord.
67
+ */
68
+ add(namespace: string, intervalMs: number, message: string, compactEvery?: number): Promise<LoopRecord>;
69
+ /**
70
+ * List all loops stored in Redis.
71
+ */
72
+ list(): Promise<LoopRecord[]>;
73
+ /**
74
+ * Pause a loop (sets status=paused and clears the timer).
75
+ */
76
+ pause(id: string): Promise<boolean>;
77
+ /**
78
+ * Resume a paused loop (sets status=active, fires immediately, then restarts timer).
79
+ */
80
+ resume(id: string): Promise<boolean>;
81
+ /**
82
+ * Delete a loop (clears timer and removes from Redis).
83
+ */
84
+ delete(id: string): Promise<boolean>;
85
+ /**
86
+ * Load all active loops from Redis, fire each immediately, then start their timers.
87
+ * Call once after the bot connects and Redis is ready.
88
+ */
89
+ start(): Promise<void>;
90
+ /**
91
+ * Stop all running timers. Does not modify Redis.
92
+ */
93
+ stop(): void;
94
+ private startTimer;
95
+ private clearTimer;
96
+ /**
97
+ * Execute one loop tick:
98
+ * 1. Reload record (picks up any pause between schedule and fire)
99
+ * 2. Increment fireCount
100
+ * 3. Duplicate check against the input queue
101
+ * 4. Optionally push /compact
102
+ * 5. Push the message
103
+ * 6. Update lastRun
104
+ */
105
+ fire(id: string): Promise<void>;
106
+ /** Write a LoopRecord to Redis as a HASH. */
107
+ private saveRecord;
108
+ /** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
109
+ private loadRecord;
110
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * LoopEngine — Redis-persisted, interval-based meta-agent messaging.
3
+ *
4
+ * Loops differ from crons:
5
+ * - Fire IMMEDIATELY on creation and on startup
6
+ * - Repeat at a fixed millisecond interval (not a cron expression)
7
+ *
8
+ * Redis key schema:
9
+ * cca:discord:loop:list — SET of loop IDs
10
+ * cca:discord:loop:{id} — HASH with fields:
11
+ * id, namespace, message, interval_ms,
12
+ * fire_count, compact_every, status,
13
+ * last_run, created_at
14
+ *
15
+ * On each fire:
16
+ * 1. Increment fire_count
17
+ * 2. Duplicate check: LRANGE cca:discord:meta:{ns}:input 0 -1
18
+ * — skip if the exact message is already queued
19
+ * 3. If fire_count % compact_every === 0: RPUSH /compact first
20
+ * 4. RPUSH JSON entry to cca:discord:meta:{ns}:input
21
+ * 5. Update last_run
22
+ */
23
+ import { randomUUID } from "crypto";
24
+ import { loopListKey, loopHashKey, discordMetaInputKey as metaInputKey, } from "@gonzih/cc-wire";
25
+ // ─── Redis key helpers ────────────────────────────────────────────────────────
26
+ const LOOP_LIST_KEY = loopListKey();
27
+ /**
28
+ * Temporary key written by the loop engine when it fires a loop.
29
+ * Value is the loopId that fired. TTL 300s.
30
+ * The bot reads this after sending a Discord message to link the message to the loop.
31
+ */
32
+ export const loopPendingKey = (ns) => `cca:discord:loop-pending:${ns}`;
33
+ /**
34
+ * Key that maps a sent Discord messageId → loopId.
35
+ * Used by the reaction handler to look up which loop to disable.
36
+ * TTL 86400s (24h).
37
+ */
38
+ export const LOOP_MESSAGE_TTL = 86400;
39
+ // ─── Human-readable interval parsing ─────────────────────────────────────────
40
+ /**
41
+ * Parse a human-readable interval string to milliseconds.
42
+ * Supports: 30s, 5m, 20m, 1h, 2h, 1d, 7d, etc.
43
+ * Returns null if the format is unrecognised.
44
+ */
45
+ export function parseIntervalMs(input) {
46
+ const trimmed = input.trim().toLowerCase();
47
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/);
48
+ if (!match)
49
+ return null;
50
+ const value = parseFloat(match[1]);
51
+ const unit = match[2];
52
+ if (value <= 0 || !isFinite(value))
53
+ return null;
54
+ switch (unit) {
55
+ case "ms": return Math.round(value);
56
+ case "s": return Math.round(value * 1_000);
57
+ case "m": return Math.round(value * 60_000);
58
+ case "h": return Math.round(value * 3_600_000);
59
+ case "d": return Math.round(value * 86_400_000);
60
+ default: return null;
61
+ }
62
+ }
63
+ // ─── LoopEngine ───────────────────────────────────────────────────────────────
64
+ export class LoopEngine {
65
+ redis;
66
+ /** Map of loop ID → NodeJS.Timeout handle */
67
+ timers = new Map();
68
+ constructor(redis) {
69
+ this.redis = redis;
70
+ }
71
+ /**
72
+ * Add a new loop.
73
+ * Fires immediately, then repeats every intervalMs milliseconds.
74
+ *
75
+ * @param namespace The meta-agent namespace to push messages to.
76
+ * @param intervalMs Milliseconds between fires.
77
+ * @param message Text content to push into the meta-agent input queue.
78
+ * @param compactEvery Push /compact every N fires. Defaults to 10. 0 = never.
79
+ * @returns The created LoopRecord.
80
+ */
81
+ async add(namespace, intervalMs, message, compactEvery = 10) {
82
+ const id = randomUUID();
83
+ const now = new Date().toISOString();
84
+ const record = {
85
+ id,
86
+ namespace,
87
+ message,
88
+ intervalMs,
89
+ fireCount: 0,
90
+ compactEvery,
91
+ status: "active",
92
+ lastRun: "",
93
+ createdAt: now,
94
+ };
95
+ await this.saveRecord(record);
96
+ await this.redis.sadd(LOOP_LIST_KEY, id);
97
+ // Fire immediately, then schedule repeating interval
98
+ await this.fire(id);
99
+ this.startTimer(record);
100
+ console.log(`[loop-engine] added loop id=${id} ns=${namespace} intervalMs=${intervalMs}`);
101
+ return record;
102
+ }
103
+ /**
104
+ * List all loops stored in Redis.
105
+ */
106
+ async list() {
107
+ const ids = await this.redis.smembers(LOOP_LIST_KEY);
108
+ const records = [];
109
+ for (const id of ids) {
110
+ const rec = await this.loadRecord(id);
111
+ if (rec)
112
+ records.push(rec);
113
+ }
114
+ return records;
115
+ }
116
+ /**
117
+ * Pause a loop (sets status=paused and clears the timer).
118
+ */
119
+ async pause(id) {
120
+ const rec = await this.loadRecord(id);
121
+ if (!rec)
122
+ return false;
123
+ rec.status = "paused";
124
+ await this.saveRecord(rec);
125
+ this.clearTimer(id);
126
+ console.log(`[loop-engine] paused loop id=${id}`);
127
+ return true;
128
+ }
129
+ /**
130
+ * Resume a paused loop (sets status=active, fires immediately, then restarts timer).
131
+ */
132
+ async resume(id) {
133
+ const rec = await this.loadRecord(id);
134
+ if (!rec)
135
+ return false;
136
+ rec.status = "active";
137
+ await this.saveRecord(rec);
138
+ // Fire immediately then start interval
139
+ await this.fire(id);
140
+ this.startTimer(rec);
141
+ console.log(`[loop-engine] resumed loop id=${id}`);
142
+ return true;
143
+ }
144
+ /**
145
+ * Delete a loop (clears timer and removes from Redis).
146
+ */
147
+ async delete(id) {
148
+ const rec = await this.loadRecord(id);
149
+ if (!rec)
150
+ return false;
151
+ this.clearTimer(id);
152
+ await this.redis.srem(LOOP_LIST_KEY, id);
153
+ await this.redis.del(loopHashKey(id));
154
+ console.log(`[loop-engine] deleted loop id=${id}`);
155
+ return true;
156
+ }
157
+ /**
158
+ * Load all active loops from Redis, fire each immediately, then start their timers.
159
+ * Call once after the bot connects and Redis is ready.
160
+ */
161
+ async start() {
162
+ const ids = await this.redis.smembers(LOOP_LIST_KEY);
163
+ let started = 0;
164
+ for (const id of ids) {
165
+ const rec = await this.loadRecord(id);
166
+ if (!rec)
167
+ continue;
168
+ if (rec.status === "active") {
169
+ await this.fire(id);
170
+ this.startTimer(rec);
171
+ started++;
172
+ }
173
+ }
174
+ console.log(`[loop-engine] started — loaded ${ids.length} loops, started ${started}`);
175
+ }
176
+ /**
177
+ * Stop all running timers. Does not modify Redis.
178
+ */
179
+ stop() {
180
+ for (const [id] of this.timers) {
181
+ this.clearTimer(id);
182
+ }
183
+ console.log("[loop-engine] stopped all timers");
184
+ }
185
+ // ─── Private helpers ────────────────────────────────────────────────────────
186
+ startTimer(rec) {
187
+ // Clear any existing timer for this ID first
188
+ this.clearTimer(rec.id);
189
+ const handle = setInterval(async () => {
190
+ await this.fire(rec.id);
191
+ }, rec.intervalMs);
192
+ this.timers.set(rec.id, handle);
193
+ }
194
+ clearTimer(id) {
195
+ const handle = this.timers.get(id);
196
+ if (handle !== undefined) {
197
+ clearInterval(handle);
198
+ this.timers.delete(id);
199
+ }
200
+ }
201
+ /**
202
+ * Execute one loop tick:
203
+ * 1. Reload record (picks up any pause between schedule and fire)
204
+ * 2. Increment fireCount
205
+ * 3. Duplicate check against the input queue
206
+ * 4. Optionally push /compact
207
+ * 5. Push the message
208
+ * 6. Update lastRun
209
+ */
210
+ async fire(id) {
211
+ const rec = await this.loadRecord(id);
212
+ if (!rec) {
213
+ console.warn(`[loop-engine] fire: record not found for id=${id}`);
214
+ return;
215
+ }
216
+ if (rec.status !== "active") {
217
+ console.log(`[loop-engine] fire: loop id=${id} is paused — skipping`);
218
+ return;
219
+ }
220
+ const ns = rec.namespace;
221
+ const inputKey = metaInputKey(ns);
222
+ // Increment fireCount
223
+ rec.fireCount += 1;
224
+ // Duplicate check: skip if the message is already queued
225
+ try {
226
+ const queued = await this.redis.lrange(inputKey, 0, -1);
227
+ const alreadyQueued = queued.some((entry) => {
228
+ try {
229
+ const parsed = JSON.parse(entry);
230
+ return parsed.content === rec.message;
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ });
236
+ if (alreadyQueued) {
237
+ console.log(`[loop-engine] fire: duplicate detected, skipping push (id=${id} ns=${ns})`);
238
+ await this.saveRecord(rec);
239
+ return;
240
+ }
241
+ }
242
+ catch (err) {
243
+ console.warn(`[loop-engine] fire: duplicate check failed (id=${id}):`, err.message);
244
+ }
245
+ // Auto-compact every N fires
246
+ if (rec.compactEvery > 0 && rec.fireCount % rec.compactEvery === 0) {
247
+ const compactEntry = JSON.stringify({
248
+ id: randomUUID(),
249
+ content: "/compact",
250
+ timestamp: new Date().toISOString(),
251
+ source: "loop",
252
+ });
253
+ try {
254
+ await this.redis.rpush(inputKey, compactEntry);
255
+ console.log(`[loop-engine] fire: pushed /compact (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
256
+ }
257
+ catch (err) {
258
+ console.warn(`[loop-engine] fire: /compact push failed (id=${id}):`, err.message);
259
+ }
260
+ }
261
+ // Push the scheduled message
262
+ const entry = JSON.stringify({
263
+ id: randomUUID(),
264
+ content: rec.message,
265
+ timestamp: new Date().toISOString(),
266
+ source: "loop",
267
+ });
268
+ try {
269
+ await this.redis.rpush(inputKey, entry);
270
+ rec.lastRun = new Date().toISOString();
271
+ console.log(`[loop-engine] fire: pushed message (id=${id} ns=${ns} fireCount=${rec.fireCount})`);
272
+ // Record which loop fired for this namespace
273
+ await this.redis.set(loopPendingKey(ns), id, "EX", 300);
274
+ }
275
+ catch (err) {
276
+ console.warn(`[loop-engine] fire: push failed (id=${id}):`, err.message);
277
+ }
278
+ await this.saveRecord(rec);
279
+ }
280
+ /** Write a LoopRecord to Redis as a HASH. */
281
+ async saveRecord(rec) {
282
+ const key = loopHashKey(rec.id);
283
+ await this.redis.hset(key, {
284
+ id: rec.id,
285
+ namespace: rec.namespace,
286
+ message: rec.message,
287
+ interval_ms: String(rec.intervalMs),
288
+ fire_count: String(rec.fireCount),
289
+ compact_every: String(rec.compactEvery),
290
+ status: rec.status,
291
+ last_run: rec.lastRun,
292
+ created_at: rec.createdAt,
293
+ });
294
+ }
295
+ /** Load a LoopRecord from Redis. Returns null if the key doesn't exist. */
296
+ async loadRecord(id) {
297
+ const data = await this.redis.hgetall(loopHashKey(id));
298
+ if (!data || !data.id)
299
+ return null;
300
+ return {
301
+ id: data.id,
302
+ namespace: data.namespace,
303
+ message: data.message,
304
+ intervalMs: parseInt(data.interval_ms ?? "0", 10),
305
+ fireCount: parseInt(data.fire_count ?? "0", 10),
306
+ compactEvery: parseInt(data.compact_every ?? "10", 10),
307
+ status: (data.status === "paused" ? "paused" : "active"),
308
+ lastRun: data.last_run ?? "",
309
+ createdAt: data.created_at ?? "",
310
+ };
311
+ }
312
+ }
@@ -4,8 +4,13 @@
4
4
  * Flow per namespace:
5
5
  * 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
6
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
7
+ * 3. ensureSession: spawn one persistent `claude --continue` process per namespace
8
+ * (no -p flag messages go via stdin)
9
+ * 4. On new message: write "${message}\n" to stdin of the running process
10
+ * 5. On process exit: remove from sessions map; next message respawns
11
+ *
12
+ * The polling loop now just drains any queued Redis messages into stdin.
13
+ * No more per-message process spawn — Claude receives messages in sequence.
9
14
  */
10
15
  import type { createCcWire } from "@gonzih/cc-wire";
11
16
  type Wire = ReturnType<typeof createCcWire>;
@@ -43,6 +48,8 @@ export declare const metaLogKey: (ns: string) => string;
43
48
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
44
49
  * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
45
50
  * Returns a Promise that resolves when the process exits.
51
+ *
52
+ * Kept for backwards-compat and direct integration-test usage.
46
53
  */
47
54
  export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
48
55
  export interface MetaAgentManager {
@@ -53,9 +60,25 @@ export interface MetaAgentManager {
53
60
  repoUrl: string;
54
61
  }>, instanceId?: string) => void;
55
62
  stop: () => void;
63
+ /** Kill and remove the persistent session for a namespace (e.g. on /clear). */
64
+ killSession: (ns: string) => void;
65
+ /** Write a raw line to the stdin of the running session, if any. */
66
+ sendToSession: (ns: string, line: string) => void;
56
67
  }
57
68
  /**
58
- * Create a MetaAgentManager that polls input queues and spawns sessions.
69
+ * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
70
+ *
71
+ * On first message for a namespace:
72
+ * 1. ensureWorkspace + injectMcp
73
+ * 2. Drain any pending Redis queue entries to stdin
74
+ * 3. Write the new message to stdin
75
+ *
76
+ * On subsequent messages: write directly to stdin of the running process.
77
+ *
78
+ * On process exit: remove from sessions map. Next message triggers a respawn.
79
+ *
80
+ * The 3-second poll loop drains any messages that arrived while a session was
81
+ * starting up or temporarily unavailable.
59
82
  */
60
83
  export declare function createMetaAgentManager(): MetaAgentManager;
61
84
  /**
@@ -4,8 +4,13 @@
4
4
  * Flow per namespace:
5
5
  * 1. ensureWorkspace: git clone repo to ~/cc-discord-workspace/{ns}
6
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
7
+ * 3. ensureSession: spawn one persistent `claude --continue` process per namespace
8
+ * (no -p flag messages go via stdin)
9
+ * 4. On new message: write "${message}\n" to stdin of the running process
10
+ * 5. On process exit: remove from sessions map; next message respawns
11
+ *
12
+ * The polling loop now just drains any queued Redis messages into stdin.
13
+ * No more per-message process spawn — Claude receives messages in sequence.
9
14
  */
10
15
  import { spawn, execSync } from "child_process";
11
16
  import { existsSync, mkdirSync, writeFileSync } from "fs";
@@ -124,25 +129,146 @@ function resolveClaude() {
124
129
  */
125
130
  export const metaStreamChannel = ccWireMetaStreamChannel;
126
131
  export const metaLogKey = ccWireMetaLogKey;
132
+ /**
133
+ * Build the env object for a claude subprocess based on the token type.
134
+ */
135
+ function buildEnv(token) {
136
+ const env = { ...process.env };
137
+ if (token.startsWith("sk-ant-api")) {
138
+ env.ANTHROPIC_API_KEY = token;
139
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
140
+ }
141
+ else {
142
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
143
+ delete env.ANTHROPIC_API_KEY;
144
+ }
145
+ return env;
146
+ }
147
+ /**
148
+ * Wire stdout from a claude subprocess into Redis.
149
+ * Parses JSONL lines from stream-json format, publishes to:
150
+ * PUBLISH cca:meta:{ns}:stream
151
+ * LPUSH cca:meta:{ns}:log (capped at 2000)
152
+ * Also dispatches assistant/result text to wire.discord.publishOutgoing.
153
+ */
154
+ function wireStdoutToRedis(proc, ns, wire) {
155
+ const rawRedis = wire._redis;
156
+ const streamCh = metaStreamChannel(ns);
157
+ const logKey = metaLogKey(ns);
158
+ const forwardEventToRedis = (eventJson) => {
159
+ rawRedis.publish(streamCh, eventJson).catch((err) => {
160
+ console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
161
+ });
162
+ rawRedis.lpush(logKey, eventJson).then(() => {
163
+ rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
164
+ }).catch((err) => {
165
+ console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
166
+ });
167
+ };
168
+ const processLine = (line) => {
169
+ const trimmed = line.trim();
170
+ if (!trimmed)
171
+ return;
172
+ let structuredEvent;
173
+ try {
174
+ const parsed = JSON.parse(trimmed);
175
+ const type = parsed.type;
176
+ if (type === "assistant") {
177
+ const content = parsed.message;
178
+ const textBlock = content?.content?.find((b) => b.type === "text");
179
+ const text = textBlock?.text ?? "";
180
+ structuredEvent = { type: "assistant", text };
181
+ if (text) {
182
+ const msg = {
183
+ id: crypto.randomUUID(),
184
+ source: "claude",
185
+ role: "assistant",
186
+ content: text,
187
+ timestamp: new Date().toISOString(),
188
+ chatId: 0,
189
+ };
190
+ wire.discord.publishOutgoing(ns, msg).catch((err) => {
191
+ console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
192
+ });
193
+ }
194
+ }
195
+ else if (type === "tool_use") {
196
+ structuredEvent = {
197
+ type: "tool_use",
198
+ name: parsed.name ?? "",
199
+ input: parsed.input ?? {},
200
+ };
201
+ }
202
+ else if (type === "tool_result") {
203
+ structuredEvent = {
204
+ type: "tool_result",
205
+ content: parsed.content ?? "",
206
+ };
207
+ }
208
+ else if (type === "result") {
209
+ const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
210
+ structuredEvent = {
211
+ type: "result",
212
+ result: resultText,
213
+ is_error: parsed.is_error ?? false,
214
+ };
215
+ if (resultText) {
216
+ const msg = {
217
+ id: crypto.randomUUID(),
218
+ source: "claude",
219
+ role: "assistant",
220
+ content: resultText,
221
+ timestamp: new Date().toISOString(),
222
+ chatId: 0,
223
+ };
224
+ wire.discord.publishOutgoing(ns, msg).catch((err) => {
225
+ console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
226
+ });
227
+ }
228
+ }
229
+ else {
230
+ structuredEvent = parsed;
231
+ }
232
+ }
233
+ catch {
234
+ structuredEvent = { type: "text", text: trimmed };
235
+ }
236
+ forwardEventToRedis(JSON.stringify(structuredEvent));
237
+ };
238
+ let lineBuffer = "";
239
+ proc.stdout.on("data", (chunk) => {
240
+ lineBuffer += chunk.toString();
241
+ const lines = lineBuffer.split("\n");
242
+ lineBuffer = lines.pop() ?? "";
243
+ for (const line of lines) {
244
+ processLine(line);
245
+ }
246
+ });
247
+ proc.stderr.on("data", (chunk) => {
248
+ const text = chunk.toString().trim();
249
+ if (text)
250
+ console.log(`[meta-agent-manager:${ns}:stderr] ${text}`);
251
+ });
252
+ proc.on("exit", () => {
253
+ // Flush remaining buffered content
254
+ if (lineBuffer.trim())
255
+ processLine(lineBuffer);
256
+ lineBuffer = "";
257
+ });
258
+ }
127
259
  /**
128
260
  * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
129
261
  * namespace workspace. Pipes stdout line-by-line → wire.discord.publishOutgoing.
130
262
  * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
131
263
  * Returns a Promise that resolves when the process exits.
264
+ *
265
+ * Kept for backwards-compat and direct integration-test usage.
132
266
  */
133
267
  export function spawnSession(ns, message, token, wire) {
134
268
  return new Promise((resolve, reject) => {
135
269
  const wsPath = workspacePath(ns);
136
270
  const claudeBin = resolveClaude();
137
- const env = { ...process.env };
138
- if (token.startsWith("sk-ant-api")) {
139
- env.ANTHROPIC_API_KEY = token;
140
- delete env.CLAUDE_CODE_OAUTH_TOKEN;
141
- }
142
- else {
143
- env.CLAUDE_CODE_OAUTH_TOKEN = token;
144
- delete env.ANTHROPIC_API_KEY;
145
- }
271
+ const env = buildEnv(token);
146
272
  const proc = spawn(claudeBin, [
147
273
  "--continue",
148
274
  "-p", message,
@@ -150,121 +276,8 @@ export function spawnSession(ns, message, token, wire) {
150
276
  "--verbose",
151
277
  "--dangerously-skip-permissions",
152
278
  ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
153
- let lineBuffer = "";
154
- /**
155
- * Parse a JSONL line from claude's stream-json stdout into a structured event.
156
- * For lines that fail JSON.parse, emit { type: "text", text: line }.
157
- * Forward each event as a JSON string to Redis:
158
- * PUBLISH cca:meta:{ns}:stream
159
- * LPUSH cca:meta:{ns}:log (capped at 2000)
160
- */
161
- const rawRedis = wire._redis;
162
- const streamCh = metaStreamChannel(ns);
163
- const logKey = metaLogKey(ns);
164
- const forwardEventToRedis = (eventJson) => {
165
- rawRedis.publish(streamCh, eventJson).catch((err) => {
166
- console.warn(`[meta-agent-manager] stream publish failed (ns=${ns}):`, err.message);
167
- });
168
- rawRedis.lpush(logKey, eventJson).then(() => {
169
- rawRedis.ltrim(logKey, 0, 1999).catch(() => { });
170
- }).catch((err) => {
171
- console.warn(`[meta-agent-manager] log lpush failed (ns=${ns}):`, err.message);
172
- });
173
- };
174
- const processLine = (line) => {
175
- const trimmed = line.trim();
176
- if (!trimmed)
177
- return;
178
- let structuredEvent;
179
- try {
180
- const parsed = JSON.parse(trimmed);
181
- // Map claude stream-json event shapes to our canonical structured event format
182
- const type = parsed.type;
183
- if (type === "assistant") {
184
- // Extract text from content blocks
185
- const content = parsed.message;
186
- const textBlock = content?.content?.find((b) => b.type === "text");
187
- const text = textBlock?.text ?? "";
188
- structuredEvent = { type: "assistant", text };
189
- // Also publish to Discord outgoing channel
190
- if (text) {
191
- const msg = {
192
- id: crypto.randomUUID(),
193
- source: "claude",
194
- role: "assistant",
195
- content: text,
196
- timestamp: new Date().toISOString(),
197
- chatId: 0,
198
- };
199
- wire.discord.publishOutgoing(ns, msg).catch((err) => {
200
- console.warn(`[meta-agent-manager] publishOutgoing failed (ns=${ns}):`, err.message);
201
- });
202
- }
203
- }
204
- else if (type === "tool_use") {
205
- structuredEvent = {
206
- type: "tool_use",
207
- name: parsed.name ?? "",
208
- input: parsed.input ?? {},
209
- };
210
- }
211
- else if (type === "tool_result") {
212
- structuredEvent = {
213
- type: "tool_result",
214
- content: parsed.content ?? "",
215
- };
216
- }
217
- else if (type === "result") {
218
- const resultText = typeof parsed.result === "string" ? parsed.result : JSON.stringify(parsed.result ?? "");
219
- structuredEvent = {
220
- type: "result",
221
- result: resultText,
222
- is_error: parsed.is_error ?? false,
223
- };
224
- // Publish result text to Discord as final assistant message
225
- if (resultText) {
226
- const msg = {
227
- id: crypto.randomUUID(),
228
- source: "claude",
229
- role: "assistant",
230
- content: resultText,
231
- timestamp: new Date().toISOString(),
232
- chatId: 0,
233
- };
234
- wire.discord.publishOutgoing(ns, msg).catch((err) => {
235
- console.warn(`[meta-agent-manager] publishOutgoing (result) failed (ns=${ns}):`, err.message);
236
- });
237
- }
238
- }
239
- else {
240
- // Forward other event types as-is
241
- structuredEvent = parsed;
242
- }
243
- }
244
- catch {
245
- // Non-JSON line — wrap as text event
246
- structuredEvent = { type: "text", text: trimmed };
247
- }
248
- forwardEventToRedis(JSON.stringify(structuredEvent));
249
- };
250
- proc.stdout.on("data", (chunk) => {
251
- lineBuffer += chunk.toString();
252
- const lines = lineBuffer.split("\n");
253
- lineBuffer = lines.pop() ?? "";
254
- for (const line of lines) {
255
- processLine(line);
256
- }
257
- });
258
- proc.stderr.on("data", (chunk) => {
259
- const text = chunk.toString().trim();
260
- if (text)
261
- console.log(`[meta-agent-manager:${ns}:stderr] ${text}`);
262
- });
279
+ wireStdoutToRedis(proc, ns, wire);
263
280
  proc.on("exit", (code) => {
264
- // Flush any remaining buffered content
265
- if (lineBuffer.trim())
266
- processLine(lineBuffer);
267
- lineBuffer = "";
268
281
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
269
282
  resolve();
270
283
  });
@@ -275,11 +288,144 @@ export function spawnSession(ns, message, token, wire) {
275
288
  });
276
289
  }
277
290
  /**
278
- * Create a MetaAgentManager that polls input queues and spawns sessions.
291
+ * Spawn a persistent `claude --continue` process for a namespace.
292
+ * No -p flag — messages are delivered via stdin.
293
+ * Stdout is wired to Redis via wireStdoutToRedis.
294
+ * Returns the ChildProcess.
295
+ */
296
+ function spawnPersistentSession(ns, token, wire, onExit) {
297
+ const wsPath = workspacePath(ns);
298
+ const claudeBin = resolveClaude();
299
+ const env = buildEnv(token);
300
+ console.log(`[meta-agent-manager] spawning persistent session (ns=${ns})`);
301
+ const proc = spawn(claudeBin, [
302
+ "--continue",
303
+ "--output-format", "stream-json",
304
+ "--verbose",
305
+ "--dangerously-skip-permissions",
306
+ ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
307
+ // Set stdin encoding so we can write strings directly
308
+ proc.stdin.setDefaultEncoding("utf8");
309
+ wireStdoutToRedis(proc, ns, wire);
310
+ proc.on("exit", (code) => {
311
+ console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
312
+ onExit();
313
+ });
314
+ proc.on("error", (err) => {
315
+ console.error(`[meta-agent-manager] persistent session spawn error (ns=${ns}):`, err.message);
316
+ onExit();
317
+ });
318
+ return proc;
319
+ }
320
+ /**
321
+ * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
322
+ *
323
+ * On first message for a namespace:
324
+ * 1. ensureWorkspace + injectMcp
325
+ * 2. Drain any pending Redis queue entries to stdin
326
+ * 3. Write the new message to stdin
327
+ *
328
+ * On subsequent messages: write directly to stdin of the running process.
329
+ *
330
+ * On process exit: remove from sessions map. Next message triggers a respawn.
331
+ *
332
+ * The 3-second poll loop drains any messages that arrived while a session was
333
+ * starting up or temporarily unavailable.
279
334
  */
280
335
  export function createMetaAgentManager() {
281
336
  let pollInterval = null;
282
- const activeNamespaces = new Set();
337
+ /** One persistent ChildProcess per namespace. */
338
+ const sessions = new Map();
339
+ /** Namespaces currently being set up (workspace clone / first spawn). */
340
+ const startingUp = new Set();
341
+ /**
342
+ * Write a line to the stdin of the persistent session for ns.
343
+ * Silently no-ops if no session exists.
344
+ */
345
+ function writeToStdin(ns, line) {
346
+ const session = sessions.get(ns);
347
+ if (!session)
348
+ return;
349
+ try {
350
+ session.proc.stdin.write(`${line}\n`);
351
+ }
352
+ catch (err) {
353
+ console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
354
+ }
355
+ }
356
+ /**
357
+ * Drain all pending messages from the Redis input queue into the session's stdin.
358
+ */
359
+ async function drainQueue(ns, wire) {
360
+ const inputKey = discordMetaInputKey(ns);
361
+ for (;;) {
362
+ let raw;
363
+ try {
364
+ raw = await wire._redis.lpop(inputKey);
365
+ }
366
+ catch {
367
+ break;
368
+ }
369
+ if (!raw)
370
+ break;
371
+ let content;
372
+ try {
373
+ content = JSON.parse(raw).content ?? raw;
374
+ }
375
+ catch {
376
+ content = raw;
377
+ }
378
+ writeToStdin(ns, content);
379
+ }
380
+ }
381
+ /**
382
+ * Ensure a persistent session exists for ns.
383
+ * If one already exists, returns immediately.
384
+ * If not, spawns one, drains any queued messages, then writes `firstMessage` if provided.
385
+ */
386
+ async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
387
+ if (sessions.has(ns)) {
388
+ if (firstMessage !== undefined)
389
+ writeToStdin(ns, firstMessage);
390
+ return;
391
+ }
392
+ if (startingUp.has(ns)) {
393
+ // Already spinning up — queue the message so drainQueue picks it up
394
+ return;
395
+ }
396
+ startingUp.add(ns);
397
+ try {
398
+ // Ensure workspace exists
399
+ const wsPath = workspacePath(ns);
400
+ await ensureWorkspace(ns, repoUrl);
401
+ injectMcp(ns, wsPath, token);
402
+ const proc = spawnPersistentSession(ns, token, wire, () => {
403
+ // On exit: remove from map so next message triggers a respawn
404
+ sessions.delete(ns);
405
+ console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
406
+ });
407
+ sessions.set(ns, { proc, ns });
408
+ // Drain any messages that arrived before this session started
409
+ await drainQueue(ns, wire);
410
+ // Write the triggering message last (after queue drain, in order)
411
+ if (firstMessage !== undefined)
412
+ writeToStdin(ns, firstMessage);
413
+ await wire.discord.setStatus(ns, {
414
+ namespace: ns,
415
+ status: "running",
416
+ isTyping: true,
417
+ turnCount: 0,
418
+ updatedAt: new Date().toISOString(),
419
+ });
420
+ }
421
+ catch (err) {
422
+ console.error(`[meta-agent-manager] ensureSession failed (ns=${ns}):`, err.message);
423
+ sessions.delete(ns);
424
+ }
425
+ finally {
426
+ startingUp.delete(ns);
427
+ }
428
+ }
283
429
  return {
284
430
  async ensureWorkspace(ns, repoUrl) {
285
431
  await ensureWorkspace(ns, repoUrl);
@@ -296,34 +442,28 @@ export function createMetaAgentManager() {
296
442
  if (namespaces.length === 0)
297
443
  return;
298
444
  for (const { namespace: ns, repoUrl } of namespaces) {
299
- if (activeNamespaces.has(ns))
300
- continue;
301
- wire.discord.dequeue(ns)
302
- .then(async (msg) => {
303
- if (!msg)
304
- return;
305
- // Staleness check: if a newer instance has registered, this process is stale — exit.
306
- if (instanceId) {
307
- try {
308
- const current = await wire._redis.get(DISCORD_INSTANCE_KEY);
309
- if (current && current !== instanceId) {
310
- console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
311
- process.exit(0);
312
- }
445
+ // Stale-instance check
446
+ if (instanceId) {
447
+ wire._redis.get(DISCORD_INSTANCE_KEY).then((current) => {
448
+ if (current && current !== instanceId) {
449
+ console.log(`[meta-agent-manager] stale instance detected (current=${current}, ours=${instanceId}) — exiting`);
450
+ process.exit(0);
313
451
  }
314
- catch {
315
- // Redis error — proceed anyway
316
- }
317
- }
318
- const content = typeof msg === "string" ? msg : msg.content ?? String(msg);
319
- activeNamespaces.add(ns);
320
- await wire.discord.setStatus(ns, {
321
- namespace: ns,
322
- status: "running",
323
- isTyping: true,
324
- turnCount: 0,
325
- updatedAt: new Date().toISOString(),
452
+ }).catch(() => { });
453
+ }
454
+ // If session exists, drain any queued messages
455
+ if (sessions.has(ns)) {
456
+ drainQueue(ns, wire).catch((err) => {
457
+ console.warn(`[meta-agent-manager] drainQueue error (ns=${ns}):`, err.message);
326
458
  });
459
+ continue;
460
+ }
461
+ // Check if anything is queued for this namespace
462
+ const inputKey = discordMetaInputKey(ns);
463
+ wire._redis.llen(inputKey).then(async (queueLen) => {
464
+ if (queueLen === 0)
465
+ return;
466
+ // Resolve token
327
467
  let token;
328
468
  try {
329
469
  token = await wire.token.getMaster();
@@ -336,38 +476,12 @@ export function createMetaAgentManager() {
336
476
  }
337
477
  if (!token) {
338
478
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
339
- activeNamespaces.delete(ns);
340
- await wire.discord.setStatus(ns, {
341
- namespace: ns,
342
- status: "idle",
343
- isTyping: false,
344
- turnCount: 0,
345
- updatedAt: new Date().toISOString(),
346
- });
347
479
  return;
348
480
  }
349
- // Ensure the workspace directory exists — idempotent if already cloned.
350
- // This guards against the workspace being absent after a bot restart.
351
- const wsPath = workspacePath(ns);
352
- await ensureWorkspace(ns, repoUrl);
353
- injectMcp(ns, wsPath, token);
354
- spawnSession(ns, content, token, wire)
355
- .catch((err) => {
356
- console.error(`[meta-agent-manager] session error (ns=${ns}):`, err.message);
357
- })
358
- .finally(() => {
359
- activeNamespaces.delete(ns);
360
- wire.discord.setStatus(ns, {
361
- namespace: ns,
362
- status: "idle",
363
- isTyping: false,
364
- turnCount: 0,
365
- updatedAt: new Date().toISOString(),
366
- }).catch(() => { });
367
- });
368
- })
369
- .catch((err) => {
370
- console.warn(`[meta-agent-manager] dequeue error (ns=${ns}):`, err.message);
481
+ // ensureSession will drain the queue itself
482
+ await ensureSession(ns, repoUrl, token, wire);
483
+ }).catch((err) => {
484
+ console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
371
485
  });
372
486
  }
373
487
  }, TIMING.INPUT_POLL_INTERVAL_MS);
@@ -379,6 +493,35 @@ export function createMetaAgentManager() {
379
493
  pollInterval = null;
380
494
  console.log("[meta-agent-manager] polling stopped");
381
495
  }
496
+ // Kill all active sessions
497
+ for (const [ns, session] of sessions) {
498
+ try {
499
+ session.proc.stdin.end();
500
+ session.proc.kill();
501
+ }
502
+ catch {
503
+ // ignore errors during shutdown
504
+ }
505
+ sessions.delete(ns);
506
+ console.log(`[meta-agent-manager] killed session on stop (ns=${ns})`);
507
+ }
508
+ },
509
+ killSession(ns) {
510
+ const session = sessions.get(ns);
511
+ if (!session)
512
+ return;
513
+ try {
514
+ session.proc.stdin.end();
515
+ session.proc.kill();
516
+ }
517
+ catch {
518
+ // ignore
519
+ }
520
+ sessions.delete(ns);
521
+ console.log(`[meta-agent-manager] killed session (ns=${ns})`);
522
+ },
523
+ sendToSession(ns, line) {
524
+ writeToStdin(ns, line);
382
525
  },
383
526
  };
384
527
  }
package/dist/notifier.js CHANGED
@@ -112,17 +112,14 @@ export function parseNotification(raw) {
112
112
  let isCron = false;
113
113
  try {
114
114
  const parsed = JSON.parse(raw);
115
- // Accept 'targets' as alias for 'routing' (coordinator sessions use this format)
116
- const routingArr = parsed.routing ?? parsed.targets;
117
115
  // routing: absent/empty → all transports; non-empty → only listed transports
118
- if (routingArr && routingArr.length > 0 && !routingArr.includes("discord")) {
116
+ if (parsed.routing && parsed.routing.length > 0 && !parsed.routing.includes("discord")) {
119
117
  return null;
120
118
  }
121
119
  if (parsed.is_cron === true)
122
120
  return null;
123
- // Accept 'message' as alias for 'text'
124
- if (parsed.text ?? parsed.message)
125
- text = (parsed.text ?? parsed.message);
121
+ if (parsed.text)
122
+ text = parsed.text;
126
123
  driver = parsed.driver;
127
124
  model = parsed.model;
128
125
  if (typeof parsed.cost === "number")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.27",
3
+ "version": "0.2.29",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "dist/"
20
20
  ],
21
21
  "dependencies": {
22
- "@gonzih/cc-wire": "^0.4.0",
22
+ "@gonzih/cc-wire": "^0.4.1",
23
23
  "@types/node-cron": "^3.0.11",
24
24
  "discord.js": "^14.0.0",
25
25
  "ioredis": "^5.0.0",