@gonzih/cc-discord 0.2.28 → 0.2.30
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.js +4 -3
- package/dist/meta-agent-manager.d.ts +26 -3
- package/dist/meta-agent-manager.js +326 -183
- package/dist/notifier.js +6 -3
- package/package.json +1 -1
package/dist/bot.js
CHANGED
|
@@ -999,6 +999,8 @@ export class CcDiscordBot {
|
|
|
999
999
|
}
|
|
1000
1000
|
case "clear": {
|
|
1001
1001
|
const ns = this.resolveNamespaceForChannel(channelId);
|
|
1002
|
+
// Kill the persistent session first so Claude releases the JSONL file
|
|
1003
|
+
this.metaAgentManager.killSession(ns);
|
|
1002
1004
|
const deleted = this.clearClaudeSession(ns);
|
|
1003
1005
|
await interaction.reply(deleted > 0
|
|
1004
1006
|
? `Context cleared for ${ns} (${deleted} session file${deleted === 1 ? "" : "s"} removed). Next message starts fresh.`
|
|
@@ -1008,9 +1010,8 @@ export class CcDiscordBot {
|
|
|
1008
1010
|
case "compact": {
|
|
1009
1011
|
const ns = this.resolveNamespaceForChannel(channelId);
|
|
1010
1012
|
await interaction.reply(`Compacting context for ${ns}...`);
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
});
|
|
1013
|
+
// Send /compact via stdin to the running persistent session
|
|
1014
|
+
this.metaAgentManager.sendToSession(ns, "/compact");
|
|
1014
1015
|
break;
|
|
1015
1016
|
}
|
|
1016
1017
|
case "cron": {
|
|
@@ -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,14 +112,17 @@ 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;
|
|
115
117
|
// routing: absent/empty → all transports; non-empty → only listed transports
|
|
116
|
-
if (
|
|
118
|
+
if (routingArr && routingArr.length > 0 && !routingArr.includes("discord")) {
|
|
117
119
|
return null;
|
|
118
120
|
}
|
|
119
121
|
if (parsed.is_cron === true)
|
|
120
122
|
return null;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
// Accept 'message' as alias for 'text'
|
|
124
|
+
if (parsed.text ?? parsed.message)
|
|
125
|
+
text = (parsed.text ?? parsed.message);
|
|
123
126
|
driver = parsed.driver;
|
|
124
127
|
model = parsed.model;
|
|
125
128
|
if (typeof parsed.cost === "number")
|