@gonzih/cc-discord 0.2.34 → 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,146 +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
- const proc = spawn(claudeBin, [
302
- "--continue",
303
- "-p",
304
- "--output-format", "stream-json",
305
- "--input-format", "stream-json",
306
- "--verbose",
307
- "--dangerously-skip-permissions",
308
- ], { cwd: wsPath, env, stdio: ["pipe", "pipe", "pipe"] });
309
- proc.stdin.setDefaultEncoding("utf8");
310
- wireStdoutToRedis(proc, ns, wire);
311
- proc.on("exit", (code) => {
312
- console.log(`[meta-agent-manager] persistent session exited (ns=${ns}, code=${code})`);
313
- onExit();
314
- });
315
- proc.on("error", (err) => {
316
- console.error(`[meta-agent-manager] persistent session spawn error (ns=${ns}):`, err.message);
317
- onExit();
318
- });
319
- return proc;
320
- }
321
- /**
322
- * Create a MetaAgentManager that maintains one persistent Claude process per namespace.
291
+ * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
323
292
  *
324
- * On first message for a namespace:
325
- * 1. ensureWorkspace + injectMcp
326
- * 2. Drain any pending Redis queue entries to stdin
327
- * 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.
328
296
  *
329
- * On subsequent messages: write directly to stdin of the running process.
330
- *
331
- * On process exit: remove from sessions map. Next message triggers a respawn.
332
- *
333
- * The 3-second poll loop drains any messages that arrived while a session was
334
- * starting up or temporarily unavailable.
297
+ * Messages queue per namespace and process sequentially no concurrent spawns
298
+ * for the same namespace.
335
299
  */
336
300
  export function createMetaAgentManager() {
337
301
  let pollInterval = null;
338
- /** One persistent ChildProcess per namespace. */
339
- const sessions = new Map();
340
- /** Namespaces currently being set up (workspace clone / first spawn). */
341
- 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();
342
308
  /**
343
- * Write a line to the stdin of the persistent session for ns.
344
- * 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.
345
311
  */
346
- function writeToStdin(ns, line) {
347
- const session = sessions.get(ns);
348
- 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)
349
320
  return;
321
+ processing.add(ns);
322
+ console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
350
323
  try {
351
- // --input-format stream-json expects: {"role":"user","content":"..."}
352
- const payload = JSON.stringify({ role: "user", content: line });
353
- session.proc.stdin.write(`${payload}\n`);
354
- }
355
- catch (err) {
356
- console.warn(`[meta-agent-manager] stdin write failed (ns=${ns}):`, err.message);
357
- }
358
- }
359
- /**
360
- * Drain all pending messages from the Redis input queue into the session's stdin.
361
- */
362
- async function drainQueue(ns, wire) {
363
- const inputKey = discordMetaInputKey(ns);
364
- for (;;) {
365
- let raw;
366
- try {
367
- raw = await wire._redis.lpop(inputKey);
368
- }
369
- catch {
370
- break;
371
- }
372
- if (!raw)
373
- break;
374
- let content;
375
- try {
376
- content = JSON.parse(raw).content ?? raw;
377
- }
378
- catch {
379
- 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
+ }
380
337
  }
381
- writeToStdin(ns, content);
338
+ }
339
+ finally {
340
+ processing.delete(ns);
341
+ console.log(`[meta-agent-manager] processQueue done (ns=${ns})`);
382
342
  }
383
343
  }
384
344
  /**
385
- * Ensure a persistent session exists for ns.
386
- * If one already exists, returns immediately.
387
- * 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.
388
346
  */
389
347
  async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
390
- if (sessions.has(ns)) {
391
- if (firstMessage !== undefined)
392
- writeToStdin(ns, firstMessage);
393
- return;
394
- }
395
- if (startingUp.has(ns)) {
396
- // Already spinning up — queue the message so drainQueue picks it up
397
- return;
398
- }
399
- startingUp.add(ns);
400
- try {
401
- // Ensure workspace exists
402
- const wsPath = workspacePath(ns);
403
- await ensureWorkspace(ns, repoUrl);
404
- injectMcp(ns, wsPath, token);
405
- const proc = spawnPersistentSession(ns, token, wire, () => {
406
- // On exit: remove from map so next message triggers a respawn
407
- sessions.delete(ns);
408
- console.log(`[meta-agent-manager] session removed from map (ns=${ns})`);
409
- });
410
- sessions.set(ns, { proc, ns });
411
- // Drain any messages that arrived before this session started
412
- await drainQueue(ns, wire);
413
- // Write the triggering message last (after queue drain, in order)
414
- if (firstMessage !== undefined)
415
- writeToStdin(ns, firstMessage);
416
- await wire.discord.setStatus(ns, {
417
- namespace: ns,
418
- status: "running",
419
- isTyping: true,
420
- turnCount: 0,
421
- updatedAt: new Date().toISOString(),
422
- });
423
- }
424
- catch (err) {
425
- console.error(`[meta-agent-manager] ensureSession failed (ns=${ns}):`, err.message);
426
- sessions.delete(ns);
427
- }
428
- finally {
429
- 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);
430
356
  }
357
+ processQueue(ns).catch((err) => {
358
+ console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
359
+ });
431
360
  }
432
361
  return {
433
362
  async ensureWorkspace(ns, repoUrl) {
@@ -439,7 +368,7 @@ export function createMetaAgentManager() {
439
368
  },
440
369
  startPolling(wire, getNamespaces, instanceId) {
441
370
  if (pollInterval)
442
- return; // already running
371
+ return;
443
372
  pollInterval = setInterval(() => {
444
373
  const namespaces = getNamespaces();
445
374
  if (namespaces.length === 0)
@@ -454,25 +383,21 @@ export function createMetaAgentManager() {
454
383
  }
455
384
  }).catch(() => { });
456
385
  }
457
- // If session exists, drain any queued messages
458
- if (sessions.has(ns)) {
459
- drainQueue(ns, wire).catch((err) => {
460
- console.warn(`[meta-agent-manager] drainQueue error (ns=${ns}):`, err.message);
461
- });
386
+ // Skip if already processing
387
+ if (processing.has(ns))
462
388
  continue;
463
- }
464
- // Check if anything is queued for this namespace
389
+ // Check Redis queue for pending messages
465
390
  const inputKey = discordMetaInputKey(ns);
466
391
  wire._redis.llen(inputKey).then(async (queueLen) => {
467
392
  if (queueLen === 0)
468
393
  return;
469
- // Resolve token
470
394
  let token;
471
395
  try {
472
396
  token = await wire.token.getMaster();
473
397
  }
474
398
  catch {
475
- token = process.env.CLAUDE_CODE_OAUTH_TOKEN
399
+ token = sessionMeta.get(ns)?.token
400
+ ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
476
401
  ?? process.env.CLAUDE_CODE_TOKEN
477
402
  ?? process.env.ANTHROPIC_API_KEY
478
403
  ?? "";
@@ -481,8 +406,31 @@ export function createMetaAgentManager() {
481
406
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
482
407
  return;
483
408
  }
484
- // ensureSession will drain the queue itself
485
- 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
+ });
486
434
  }).catch((err) => {
487
435
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
488
436
  });
@@ -496,35 +444,20 @@ export function createMetaAgentManager() {
496
444
  pollInterval = null;
497
445
  console.log("[meta-agent-manager] polling stopped");
498
446
  }
499
- // Kill all active sessions
500
- for (const [ns, session] of sessions) {
501
- try {
502
- session.proc.stdin.end();
503
- session.proc.kill();
504
- }
505
- catch {
506
- // ignore errors during shutdown
507
- }
508
- sessions.delete(ns);
509
- console.log(`[meta-agent-manager] killed session on stop (ns=${ns})`);
510
- }
447
+ queues.clear();
511
448
  },
512
449
  killSession(ns) {
513
- const session = sessions.get(ns);
514
- if (!session)
515
- return;
516
- try {
517
- session.proc.stdin.end();
518
- session.proc.kill();
519
- }
520
- catch {
521
- // ignore
522
- }
523
- sessions.delete(ns);
524
- 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})`);
525
453
  },
526
454
  sendToSession(ns, line) {
527
- 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
+ });
528
461
  },
529
462
  };
530
463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.34",
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": {