@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 +13 -0
- package/dist/bot.js +122 -3
- package/dist/index.js +2 -0
- package/dist/loop-engine.d.ts +110 -0
- package/dist/loop-engine.js +312 -0
- package/dist/meta-agent-manager.d.ts +26 -3
- package/dist/meta-agent-manager.js +326 -183
- package/dist/notifier.js +3 -6
- package/package.json +2 -2
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
|
-
|
|
987
|
-
|
|
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.
|
|
8
|
-
*
|
|
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
|
|
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.
|
|
8
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 (
|
|
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
|
-
|
|
124
|
-
|
|
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.
|
|
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.
|
|
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",
|