@gonzih/cc-discord 0.2.35 → 0.2.36

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";
@@ -257,12 +256,9 @@ function wireStdoutToRedis(proc, ns, wire) {
257
256
  });
258
257
  }
259
258
  /**
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.
259
+ * Spawn `claude --continue -p` with message written to stdin, then close stdin (EOF).
260
+ * Claude responds and exits. Context is maintained via --continue (JSONL session file).
263
261
  * Returns a Promise that resolves when the process exits.
264
- *
265
- * Kept for backwards-compat and direct integration-test usage.
266
262
  */
267
263
  export function spawnSession(ns, message, token, wire) {
268
264
  return new Promise((resolve, reject) => {
@@ -271,11 +267,15 @@ export function spawnSession(ns, message, token, wire) {
271
267
  const env = buildEnv(token);
272
268
  const proc = spawn(claudeBin, [
273
269
  "--continue",
274
- "-p", message,
270
+ "-p",
275
271
  "--output-format", "stream-json",
276
272
  "--verbose",
277
273
  "--dangerously-skip-permissions",
278
- ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
274
+ ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
275
+ // Write message to stdin, then close (EOF triggers Claude to process and respond)
276
+ proc.stdin.write(message + "\n", () => {
277
+ proc.stdin.end();
278
+ });
279
279
  wireStdoutToRedis(proc, ns, wire);
280
280
  proc.on("exit", (code) => {
281
281
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
@@ -288,149 +288,75 @@ export function spawnSession(ns, message, token, wire) {
288
288
  });
289
289
  }
290
290
  /**
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.
291
+ * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
327
292
  *
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
293
+ * Each message spawns `claude --continue -p` with the message written to stdin,
294
+ * then stdin is closed (EOF) so Claude processes and exits. --continue maintains
295
+ * conversation history via the JSONL session file in the workspace.
332
296
  *
333
- * On subsequent messages: write directly to stdin of the running process.
334
- *
335
- * On process exit: remove from sessions map. Next message triggers a respawn.
336
- *
337
- * The 3-second poll loop drains any messages that arrived while a session was
338
- * starting up or temporarily unavailable.
297
+ * Messages queue per namespace and process sequentially no concurrent spawns
298
+ * for the same namespace.
339
299
  */
340
300
  export function createMetaAgentManager() {
341
301
  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();
302
+ // Per-namespace message queue (local, drained by processQueue)
303
+ const queues = new Map();
304
+ // Namespaces currently running a spawnSession (blocks concurrent spawns)
305
+ const processing = new Set();
306
+ // Cached session metadata (token, wire, repoUrl) per namespace
307
+ const sessionMeta = new Map();
346
308
  /**
347
- * Write a line to the stdin of the persistent session for ns.
348
- * Silently no-ops if no session exists.
309
+ * Drain the local queue for a namespace, spawning one session per message.
310
+ * No-ops if already processing or queue is empty.
349
311
  */
350
- function writeToStdin(ns, line) {
351
- const session = sessions.get(ns);
352
- if (!session)
312
+ async function processQueue(ns) {
313
+ if (processing.has(ns))
314
+ return;
315
+ const queue = queues.get(ns);
316
+ if (!queue || queue.length === 0)
317
+ return;
318
+ const meta = sessionMeta.get(ns);
319
+ if (!meta)
353
320
  return;
321
+ processing.add(ns);
322
+ console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
354
323
  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;
324
+ while (true) {
325
+ const q = queues.get(ns);
326
+ if (!q || q.length === 0)
327
+ break;
328
+ const message = q.shift();
329
+ const { token, wire } = sessionMeta.get(ns) ?? meta;
330
+ console.log(`[meta-agent-manager] spawning session for message (ns=${ns})`);
331
+ try {
332
+ await spawnSession(ns, message, token, wire);
333
+ }
334
+ catch (err) {
335
+ console.error(`[meta-agent-manager] spawnSession error (ns=${ns}):`, err.message);
336
+ }
383
337
  }
384
- writeToStdin(ns, content);
338
+ }
339
+ finally {
340
+ processing.delete(ns);
341
+ console.log(`[meta-agent-manager] processQueue done (ns=${ns})`);
385
342
  }
386
343
  }
387
344
  /**
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.
345
+ * Ensure workspace + MCP are ready, add message to queue, start processing.
391
346
  */
392
347
  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);
348
+ sessionMeta.set(ns, { token, wire, repoUrl });
349
+ const wsPath = workspacePath(ns);
350
+ await ensureWorkspace(ns, repoUrl);
351
+ injectMcp(ns, wsPath, token);
352
+ if (!queues.has(ns))
353
+ queues.set(ns, []);
354
+ if (firstMessage !== undefined) {
355
+ queues.get(ns).push(firstMessage);
433
356
  }
357
+ processQueue(ns).catch((err) => {
358
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
359
+ });
434
360
  }
435
361
  return {
436
362
  async ensureWorkspace(ns, repoUrl) {
@@ -442,7 +368,7 @@ export function createMetaAgentManager() {
442
368
  },
443
369
  startPolling(wire, getNamespaces, instanceId) {
444
370
  if (pollInterval)
445
- return; // already running
371
+ return;
446
372
  pollInterval = setInterval(() => {
447
373
  const namespaces = getNamespaces();
448
374
  if (namespaces.length === 0)
@@ -457,25 +383,21 @@ export function createMetaAgentManager() {
457
383
  }
458
384
  }).catch(() => { });
459
385
  }
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
- });
386
+ // Skip if already processing
387
+ if (processing.has(ns))
465
388
  continue;
466
- }
467
- // Check if anything is queued for this namespace
389
+ // Check Redis queue for pending messages
468
390
  const inputKey = discordMetaInputKey(ns);
469
391
  wire._redis.llen(inputKey).then(async (queueLen) => {
470
392
  if (queueLen === 0)
471
393
  return;
472
- // Resolve token
473
394
  let token;
474
395
  try {
475
396
  token = await wire.token.getMaster();
476
397
  }
477
398
  catch {
478
- token = process.env.CLAUDE_CODE_OAUTH_TOKEN
399
+ token = sessionMeta.get(ns)?.token
400
+ ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
479
401
  ?? process.env.CLAUDE_CODE_TOKEN
480
402
  ?? process.env.ANTHROPIC_API_KEY
481
403
  ?? "";
@@ -484,8 +406,31 @@ export function createMetaAgentManager() {
484
406
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
485
407
  return;
486
408
  }
487
- // ensureSession will drain the queue itself
488
- await ensureSession(ns, repoUrl, token, wire);
409
+ // Drain Redis queue into local queue
410
+ if (!queues.has(ns))
411
+ queues.set(ns, []);
412
+ for (;;) {
413
+ const raw = await wire._redis.lpop(inputKey).catch(() => null);
414
+ if (!raw)
415
+ break;
416
+ let content;
417
+ try {
418
+ content = JSON.parse(raw).content ?? raw;
419
+ }
420
+ catch {
421
+ content = raw;
422
+ }
423
+ queues.get(ns).push(content);
424
+ }
425
+ if (!sessionMeta.has(ns)) {
426
+ sessionMeta.set(ns, { token, wire, repoUrl });
427
+ }
428
+ const wsPath = workspacePath(ns);
429
+ await ensureWorkspace(ns, repoUrl);
430
+ injectMcp(ns, wsPath, token);
431
+ processQueue(ns).catch((err) => {
432
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
433
+ });
489
434
  }).catch((err) => {
490
435
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
491
436
  });
@@ -499,35 +444,20 @@ export function createMetaAgentManager() {
499
444
  pollInterval = null;
500
445
  console.log("[meta-agent-manager] polling stopped");
501
446
  }
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
- }
447
+ queues.clear();
514
448
  },
515
449
  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})`);
450
+ // Clear local queue — any in-flight spawn will complete naturally
451
+ queues.delete(ns);
452
+ console.log(`[meta-agent-manager] killed session queue (ns=${ns})`);
528
453
  },
529
454
  sendToSession(ns, line) {
530
- writeToStdin(ns, line);
455
+ if (!queues.has(ns))
456
+ queues.set(ns, []);
457
+ queues.get(ns).push(line);
458
+ processQueue(ns).catch((err) => {
459
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
460
+ });
531
461
  },
532
462
  };
533
463
  }
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.36",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {