@gonzih/cc-discord 0.2.35 → 0.2.37

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.
@@ -4,13 +4,12 @@
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. 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
7
+ * 3. ensureSession: add message to per-namespace queue, start processQueue
8
+ * 4. processQueue: spawn `claude --continue -p` (stdin+EOF) per message, one at a time
9
+ * 5. Context is maintained via --continue (JSONL session file in workspace)
11
10
  *
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.
11
+ * Spawn-per-message with --continue maintains full conversation history.
12
+ * Messages queue up and process sequentially per namespace.
14
13
  */
15
14
  import type { createCcWire } from "@gonzih/cc-wire";
16
15
  type Wire = ReturnType<typeof createCcWire>;
@@ -44,12 +43,9 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
44
43
  export declare const metaStreamChannel: (ns: string) => string;
45
44
  export declare const metaLogKey: (ns: string) => string;
46
45
  /**
47
- * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
48
- * namespace workspace. Pipes stdout line-by-line wire.discord.publishOutgoing.
49
- * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
46
+ * Spawn `claude --continue -p` with message written to stdin, then close stdin (EOF).
47
+ * Claude responds and exits. Context is maintained via --continue (JSONL session file).
50
48
  * Returns a Promise that resolves when the process exits.
51
- *
52
- * Kept for backwards-compat and direct integration-test usage.
53
49
  */
54
50
  export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
55
51
  export interface MetaAgentManager {
@@ -60,25 +56,20 @@ export interface MetaAgentManager {
60
56
  repoUrl: string;
61
57
  }>, instanceId?: string) => void;
62
58
  stop: () => void;
63
- /** Kill and remove the persistent session for a namespace (e.g. on /clear). */
59
+ /** Clear the message queue for a namespace (e.g. on /clear). */
64
60
  killSession: (ns: string) => void;
65
- /** Write a raw line to the stdin of the running session, if any. */
61
+ /** Queue a message for a namespace. */
66
62
  sendToSession: (ns: string, line: string) => void;
67
63
  }
68
64
  /**
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.
65
+ * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
77
66
  *
78
- * On process exit: remove from sessions map. Next message triggers a respawn.
67
+ * Each message spawns `claude --continue -p` with the message written to stdin,
68
+ * then stdin is closed (EOF) so Claude processes and exits. --continue maintains
69
+ * conversation history via the JSONL session file in the workspace.
79
70
  *
80
- * The 3-second poll loop drains any messages that arrived while a session was
81
- * starting up or temporarily unavailable.
71
+ * Messages queue per namespace and process sequentially no concurrent spawns
72
+ * for the same namespace.
82
73
  */
83
74
  export declare function createMetaAgentManager(): MetaAgentManager;
84
75
  /**
@@ -4,13 +4,12 @@
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. 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
7
+ * 3. ensureSession: add message to per-namespace queue, start processQueue
8
+ * 4. processQueue: spawn `claude --continue -p` (stdin+EOF) per message, one at a time
9
+ * 5. Context is maintained via --continue (JSONL session file in workspace)
11
10
  *
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.
11
+ * Spawn-per-message with --continue maintains full conversation history.
12
+ * Messages queue up and process sequentially per namespace.
14
13
  */
15
14
  import { spawn, execSync } from "child_process";
16
15
  import { existsSync, mkdirSync, writeFileSync } from "fs";
@@ -212,19 +211,7 @@ function wireStdoutToRedis(proc, ns, wire) {
212
211
  result: resultText,
213
212
  is_error: parsed.is_error ?? false,
214
213
  };
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
- }
214
+ // Do not re-publish result text — assistant event already published it
228
215
  }
229
216
  else {
230
217
  structuredEvent = parsed;
@@ -257,12 +244,9 @@ function wireStdoutToRedis(proc, ns, wire) {
257
244
  });
258
245
  }
259
246
  /**
260
- * Spawn `claude --continue -p "{message}" --dangerously-skip-permissions` in the
261
- * namespace workspace. Pipes stdout line-by-line wire.discord.publishOutgoing.
262
- * Also streams each chunk to Redis: PUBLISH cca:meta:{ns}:stream and LPUSH cca:meta:{ns}:log.
247
+ * Spawn `claude --continue -p` with message written to stdin, then close stdin (EOF).
248
+ * Claude responds and exits. Context is maintained via --continue (JSONL session file).
263
249
  * Returns a Promise that resolves when the process exits.
264
- *
265
- * Kept for backwards-compat and direct integration-test usage.
266
250
  */
267
251
  export function spawnSession(ns, message, token, wire) {
268
252
  return new Promise((resolve, reject) => {
@@ -271,11 +255,15 @@ export function spawnSession(ns, message, token, wire) {
271
255
  const env = buildEnv(token);
272
256
  const proc = spawn(claudeBin, [
273
257
  "--continue",
274
- "-p", message,
258
+ "-p",
275
259
  "--output-format", "stream-json",
276
260
  "--verbose",
277
261
  "--dangerously-skip-permissions",
278
- ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
262
+ ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
263
+ // Write message to stdin, then close (EOF triggers Claude to process and respond)
264
+ proc.stdin.write(message + "\n", () => {
265
+ proc.stdin.end();
266
+ });
279
267
  wireStdoutToRedis(proc, ns, wire);
280
268
  proc.on("exit", (code) => {
281
269
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
@@ -288,149 +276,75 @@ export function spawnSession(ns, message, token, wire) {
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
- // DO NOT ADD -p OR --input-format stream-json HERE.
302
- // With -p + --input-format stream-json, Claude buffers all stdin until EOF before responding.
303
- // Since the stdin pipe is never closed (it stays open for subsequent messages), Claude
304
- // waits forever and never produces output. Interactive mode (no -p) reads one line at a
305
- // time and responds immediately. Verified by direct test: `echo msg | claude --continue
306
- // --output-format stream-json ...` responds correctly; the -p variant does not.
307
- const proc = spawn(claudeBin, [
308
- "--continue",
309
- "--output-format", "stream-json",
310
- "--verbose",
311
- "--dangerously-skip-permissions",
312
- ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
313
- proc.stdin.setDefaultEncoding("utf8");
314
- wireStdoutToRedis(proc, ns, wire);
315
- proc.on("exit", (code) => {
316
- console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
317
- onExit();
318
- });
319
- proc.on("error", (err) => {
320
- console.error(`[meta-agent-manager] persistent session spawn error (ns=${ns}):`, err.message);
321
- onExit();
322
- });
323
- return proc;
324
- }
325
- /**
326
- * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
327
- *
328
- * On first message for a namespace:
329
- * 1. ensureWorkspace + injectMcp
330
- * 2. Drain any pending Redis queue entries to stdin
331
- * 3. Write the new message to stdin
332
- *
333
- * On subsequent messages: write directly to stdin of the running process.
279
+ * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
334
280
  *
335
- * On process exit: remove from sessions map. Next message triggers a respawn.
281
+ * Each message spawns `claude --continue -p` with the message written to stdin,
282
+ * then stdin is closed (EOF) so Claude processes and exits. --continue maintains
283
+ * conversation history via the JSONL session file in the workspace.
336
284
  *
337
- * The 3-second poll loop drains any messages that arrived while a session was
338
- * starting up or temporarily unavailable.
285
+ * Messages queue per namespace and process sequentially no concurrent spawns
286
+ * for the same namespace.
339
287
  */
340
288
  export function createMetaAgentManager() {
341
289
  let pollInterval = null;
342
- /** One persistent ChildProcess per namespace. */
343
- const sessions = new Map();
344
- /** Namespaces currently being set up (workspace clone / first spawn). */
345
- const startingUp = new Set();
290
+ // Per-namespace message queue (local, drained by processQueue)
291
+ const queues = new Map();
292
+ // Namespaces currently running a spawnSession (blocks concurrent spawns)
293
+ const processing = new Set();
294
+ // Cached session metadata (token, wire, repoUrl) per namespace
295
+ const sessionMeta = new Map();
346
296
  /**
347
- * Write a line to the stdin of the persistent session for ns.
348
- * Silently no-ops if no session exists.
297
+ * Drain the local queue for a namespace, spawning one session per message.
298
+ * No-ops if already processing or queue is empty.
349
299
  */
350
- function writeToStdin(ns, line) {
351
- const session = sessions.get(ns);
352
- if (!session)
300
+ async function processQueue(ns) {
301
+ if (processing.has(ns))
302
+ return;
303
+ const queue = queues.get(ns);
304
+ if (!queue || queue.length === 0)
305
+ return;
306
+ const meta = sessionMeta.get(ns);
307
+ if (!meta)
353
308
  return;
309
+ processing.add(ns);
310
+ console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
354
311
  try {
355
- // Interactive mode: plain text line, Claude reads it as user input
356
- session.proc.stdin.write(`${line}\n`);
357
- }
358
- catch (err) {
359
- console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
360
- }
361
- }
362
- /**
363
- * Drain all pending messages from the Redis input queue into the session's stdin.
364
- */
365
- async function drainQueue(ns, wire) {
366
- const inputKey = discordMetaInputKey(ns);
367
- for (;;) {
368
- let raw;
369
- try {
370
- raw = await wire._redis.lpop(inputKey);
371
- }
372
- catch {
373
- break;
374
- }
375
- if (!raw)
376
- break;
377
- let content;
378
- try {
379
- content = JSON.parse(raw).content ?? raw;
380
- }
381
- catch {
382
- content = raw;
312
+ while (true) {
313
+ const q = queues.get(ns);
314
+ if (!q || q.length === 0)
315
+ break;
316
+ const message = q.shift();
317
+ const { token, wire } = sessionMeta.get(ns) ?? meta;
318
+ console.log(`[meta-agent-manager] spawning session for message (ns=${ns})`);
319
+ try {
320
+ await spawnSession(ns, message, token, wire);
321
+ }
322
+ catch (err) {
323
+ console.error(`[meta-agent-manager] spawnSession error (ns=${ns}):`, err.message);
324
+ }
383
325
  }
384
- writeToStdin(ns, content);
326
+ }
327
+ finally {
328
+ processing.delete(ns);
329
+ console.log(`[meta-agent-manager] processQueue done (ns=${ns})`);
385
330
  }
386
331
  }
387
332
  /**
388
- * Ensure a persistent session exists for ns.
389
- * If one already exists, returns immediately.
390
- * If not, spawns one, drains any queued messages, then writes `firstMessage` if provided.
333
+ * Ensure workspace + MCP are ready, add message to queue, start processing.
391
334
  */
392
335
  async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
393
- if (sessions.has(ns)) {
394
- if (firstMessage !== undefined)
395
- writeToStdin(ns, firstMessage);
396
- return;
397
- }
398
- if (startingUp.has(ns)) {
399
- // Already spinning up — queue the message so drainQueue picks it up
400
- return;
401
- }
402
- startingUp.add(ns);
403
- try {
404
- // Ensure workspace exists
405
- const wsPath = workspacePath(ns);
406
- await ensureWorkspace(ns, repoUrl);
407
- injectMcp(ns, wsPath, token);
408
- const proc = spawnPersistentSession(ns, token, wire, () => {
409
- // On exit: remove from map so next message triggers a respawn
410
- sessions.delete(ns);
411
- console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
412
- });
413
- sessions.set(ns, { proc, ns });
414
- // Drain any messages that arrived before this session started
415
- await drainQueue(ns, wire);
416
- // Write the triggering message last (after queue drain, in order)
417
- if (firstMessage !== undefined)
418
- writeToStdin(ns, firstMessage);
419
- await wire.discord.setStatus(ns, {
420
- namespace: ns,
421
- status: "running",
422
- isTyping: true,
423
- turnCount: 0,
424
- updatedAt: new Date().toISOString(),
425
- });
426
- }
427
- catch (err) {
428
- console.error(`[meta-agent-manager] ensureSession failed (ns=${ns}):`, err.message);
429
- sessions.delete(ns);
430
- }
431
- finally {
432
- startingUp.delete(ns);
336
+ sessionMeta.set(ns, { token, wire, repoUrl });
337
+ const wsPath = workspacePath(ns);
338
+ await ensureWorkspace(ns, repoUrl);
339
+ injectMcp(ns, wsPath, token);
340
+ if (!queues.has(ns))
341
+ queues.set(ns, []);
342
+ if (firstMessage !== undefined) {
343
+ queues.get(ns).push(firstMessage);
433
344
  }
345
+ processQueue(ns).catch((err) => {
346
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
347
+ });
434
348
  }
435
349
  return {
436
350
  async ensureWorkspace(ns, repoUrl) {
@@ -442,7 +356,7 @@ export function createMetaAgentManager() {
442
356
  },
443
357
  startPolling(wire, getNamespaces, instanceId) {
444
358
  if (pollInterval)
445
- return; // already running
359
+ return;
446
360
  pollInterval = setInterval(() => {
447
361
  const namespaces = getNamespaces();
448
362
  if (namespaces.length === 0)
@@ -457,25 +371,21 @@ export function createMetaAgentManager() {
457
371
  }
458
372
  }).catch(() => { });
459
373
  }
460
- // If session exists, drain any queued messages
461
- if (sessions.has(ns)) {
462
- drainQueue(ns, wire).catch((err) => {
463
- console.warn(`[meta-agent-manager] drainQueue error (ns=${ns}):`, err.message);
464
- });
374
+ // Skip if already processing
375
+ if (processing.has(ns))
465
376
  continue;
466
- }
467
- // Check if anything is queued for this namespace
377
+ // Check Redis queue for pending messages
468
378
  const inputKey = discordMetaInputKey(ns);
469
379
  wire._redis.llen(inputKey).then(async (queueLen) => {
470
380
  if (queueLen === 0)
471
381
  return;
472
- // Resolve token
473
382
  let token;
474
383
  try {
475
384
  token = await wire.token.getMaster();
476
385
  }
477
386
  catch {
478
- token = process.env.CLAUDE_CODE_OAUTH_TOKEN
387
+ token = sessionMeta.get(ns)?.token
388
+ ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
479
389
  ?? process.env.CLAUDE_CODE_TOKEN
480
390
  ?? process.env.ANTHROPIC_API_KEY
481
391
  ?? "";
@@ -484,8 +394,31 @@ export function createMetaAgentManager() {
484
394
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
485
395
  return;
486
396
  }
487
- // ensureSession will drain the queue itself
488
- await ensureSession(ns, repoUrl, token, wire);
397
+ // Drain Redis queue into local queue
398
+ if (!queues.has(ns))
399
+ queues.set(ns, []);
400
+ for (;;) {
401
+ const raw = await wire._redis.lpop(inputKey).catch(() => null);
402
+ if (!raw)
403
+ break;
404
+ let content;
405
+ try {
406
+ content = JSON.parse(raw).content ?? raw;
407
+ }
408
+ catch {
409
+ content = raw;
410
+ }
411
+ queues.get(ns).push(content);
412
+ }
413
+ if (!sessionMeta.has(ns)) {
414
+ sessionMeta.set(ns, { token, wire, repoUrl });
415
+ }
416
+ const wsPath = workspacePath(ns);
417
+ await ensureWorkspace(ns, repoUrl);
418
+ injectMcp(ns, wsPath, token);
419
+ processQueue(ns).catch((err) => {
420
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
421
+ });
489
422
  }).catch((err) => {
490
423
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
491
424
  });
@@ -499,35 +432,20 @@ export function createMetaAgentManager() {
499
432
  pollInterval = null;
500
433
  console.log("[meta-agent-manager] polling stopped");
501
434
  }
502
- // Kill all active sessions
503
- for (const [ns, session] of sessions) {
504
- try {
505
- session.proc.stdin.end();
506
- session.proc.kill();
507
- }
508
- catch {
509
- // ignore errors during shutdown
510
- }
511
- sessions.delete(ns);
512
- console.log(`[meta-agent-manager] killed session on stop (ns=${ns})`);
513
- }
435
+ queues.clear();
514
436
  },
515
437
  killSession(ns) {
516
- const session = sessions.get(ns);
517
- if (!session)
518
- return;
519
- try {
520
- session.proc.stdin.end();
521
- session.proc.kill();
522
- }
523
- catch {
524
- // ignore
525
- }
526
- sessions.delete(ns);
527
- console.log(`[meta-agent-manager] killed session (ns=${ns})`);
438
+ // Clear local queue — any in-flight spawn will complete naturally
439
+ queues.delete(ns);
440
+ console.log(`[meta-agent-manager] killed session queue (ns=${ns})`);
528
441
  },
529
442
  sendToSession(ns, line) {
530
- writeToStdin(ns, line);
443
+ if (!queues.has(ns))
444
+ queues.set(ns, []);
445
+ queues.get(ns).push(line);
446
+ processQueue(ns).catch((err) => {
447
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
448
+ });
531
449
  },
532
450
  };
533
451
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {