@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 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
- this.compactClaudeSession(ns).catch((err) => {
1012
- console.warn(`[bot] /compact failed (ns=${ns}):`, err.message);
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. 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,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 (parsed.routing && parsed.routing.length > 0 && !parsed.routing.includes("discord")) {
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
- if (parsed.text)
122
- text = parsed.text;
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")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {