@gonzih/cc-discord 0.2.36 → 0.2.38

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,12 +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. 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)
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
10
11
  *
11
- * Spawn-per-message with --continue maintains full conversation history.
12
- * Messages queue up and process sequentially per namespace.
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.
13
14
  */
14
15
  import type { createCcWire } from "@gonzih/cc-wire";
15
16
  type Wire = ReturnType<typeof createCcWire>;
@@ -43,9 +44,12 @@ export declare function injectMcp(ns: string, wsPath: string, token: string): vo
43
44
  export declare const metaStreamChannel: (ns: string) => string;
44
45
  export declare const metaLogKey: (ns: string) => string;
45
46
  /**
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).
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.
48
50
  * Returns a Promise that resolves when the process exits.
51
+ *
52
+ * Kept for backwards-compat and direct integration-test usage.
49
53
  */
50
54
  export declare function spawnSession(ns: string, message: string, token: string, wire: Wire): Promise<void>;
51
55
  export interface MetaAgentManager {
@@ -56,20 +60,25 @@ export interface MetaAgentManager {
56
60
  repoUrl: string;
57
61
  }>, instanceId?: string) => void;
58
62
  stop: () => void;
59
- /** Clear the message queue for a namespace (e.g. on /clear). */
63
+ /** Kill and remove the persistent session for a namespace (e.g. on /clear). */
60
64
  killSession: (ns: string) => void;
61
- /** Queue a message for a namespace. */
65
+ /** Write a raw line to the stdin of the running session, if any. */
62
66
  sendToSession: (ns: string, line: string) => void;
63
67
  }
64
68
  /**
65
- * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
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.
66
77
  *
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.
78
+ * On process exit: remove from sessions map. Next message triggers a respawn.
70
79
  *
71
- * Messages queue per namespace and process sequentially no concurrent spawns
72
- * for the same namespace.
80
+ * The 3-second poll loop drains any messages that arrived while a session was
81
+ * starting up or temporarily unavailable.
73
82
  */
74
83
  export declare function createMetaAgentManager(): MetaAgentManager;
75
84
  /**
@@ -4,12 +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. 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)
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
10
11
  *
11
- * Spawn-per-message with --continue maintains full conversation history.
12
- * Messages queue up and process sequentially per namespace.
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.
13
14
  */
14
15
  import { spawn, execSync } from "child_process";
15
16
  import { existsSync, mkdirSync, writeFileSync } from "fs";
@@ -256,9 +257,12 @@ function wireStdoutToRedis(proc, ns, wire) {
256
257
  });
257
258
  }
258
259
  /**
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).
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.
261
263
  * Returns a Promise that resolves when the process exits.
264
+ *
265
+ * Kept for backwards-compat and direct integration-test usage.
262
266
  */
263
267
  export function spawnSession(ns, message, token, wire) {
264
268
  return new Promise((resolve, reject) => {
@@ -267,15 +271,11 @@ export function spawnSession(ns, message, token, wire) {
267
271
  const env = buildEnv(token);
268
272
  const proc = spawn(claudeBin, [
269
273
  "--continue",
270
- "-p",
274
+ "-p", message,
271
275
  "--output-format", "stream-json",
272
276
  "--verbose",
273
277
  "--dangerously-skip-permissions",
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
- });
278
+ ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
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,75 +288,149 @@ export function spawnSession(ns, message, token, wire) {
288
288
  });
289
289
  }
290
290
  /**
291
- * Create a MetaAgentManager that processes Discord messages via spawn-per-message.
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.
292
327
  *
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
+ * 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
296
332
  *
297
- * Messages queue per namespace and process sequentially no concurrent spawns
298
- * for the same namespace.
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.
299
339
  */
300
340
  export function createMetaAgentManager() {
301
341
  let pollInterval = null;
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
+ /** 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();
308
346
  /**
309
- * Drain the local queue for a namespace, spawning one session per message.
310
- * No-ops if already processing or queue is empty.
347
+ * Write a line to the stdin of the persistent session for ns.
348
+ * Silently no-ops if no session exists.
311
349
  */
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)
350
+ function writeToStdin(ns, line) {
351
+ const session = sessions.get(ns);
352
+ if (!session)
320
353
  return;
321
- processing.add(ns);
322
- console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
323
354
  try {
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
- }
337
- }
355
+ // Interactive mode: plain text line, Claude reads it as user input
356
+ session.proc.stdin.write(`${line}\n`);
338
357
  }
339
- finally {
340
- processing.delete(ns);
341
- console.log(`[meta-agent-manager] processQueue done (ns=${ns})`);
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;
383
+ }
384
+ writeToStdin(ns, content);
342
385
  }
343
386
  }
344
387
  /**
345
- * Ensure workspace + MCP are ready, add message to queue, start processing.
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.
346
391
  */
347
392
  async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
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);
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);
356
433
  }
357
- processQueue(ns).catch((err) => {
358
- console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
359
- });
360
434
  }
361
435
  return {
362
436
  async ensureWorkspace(ns, repoUrl) {
@@ -368,7 +442,7 @@ export function createMetaAgentManager() {
368
442
  },
369
443
  startPolling(wire, getNamespaces, instanceId) {
370
444
  if (pollInterval)
371
- return;
445
+ return; // already running
372
446
  pollInterval = setInterval(() => {
373
447
  const namespaces = getNamespaces();
374
448
  if (namespaces.length === 0)
@@ -383,21 +457,25 @@ export function createMetaAgentManager() {
383
457
  }
384
458
  }).catch(() => { });
385
459
  }
386
- // Skip if already processing
387
- if (processing.has(ns))
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
+ });
388
465
  continue;
389
- // Check Redis queue for pending messages
466
+ }
467
+ // Check if anything is queued for this namespace
390
468
  const inputKey = discordMetaInputKey(ns);
391
469
  wire._redis.llen(inputKey).then(async (queueLen) => {
392
470
  if (queueLen === 0)
393
471
  return;
472
+ // Resolve token
394
473
  let token;
395
474
  try {
396
475
  token = await wire.token.getMaster();
397
476
  }
398
477
  catch {
399
- token = sessionMeta.get(ns)?.token
400
- ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
478
+ token = process.env.CLAUDE_CODE_OAUTH_TOKEN
401
479
  ?? process.env.CLAUDE_CODE_TOKEN
402
480
  ?? process.env.ANTHROPIC_API_KEY
403
481
  ?? "";
@@ -406,31 +484,8 @@ export function createMetaAgentManager() {
406
484
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
407
485
  return;
408
486
  }
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
- });
487
+ // ensureSession will drain the queue itself
488
+ await ensureSession(ns, repoUrl, token, wire);
434
489
  }).catch((err) => {
435
490
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
436
491
  });
@@ -444,20 +499,35 @@ export function createMetaAgentManager() {
444
499
  pollInterval = null;
445
500
  console.log("[meta-agent-manager] polling stopped");
446
501
  }
447
- queues.clear();
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
+ }
448
514
  },
449
515
  killSession(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})`);
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})`);
453
528
  },
454
529
  sendToSession(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
- });
530
+ writeToStdin(ns, line);
461
531
  },
462
532
  };
463
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "description": "Claude Code Discord bot — chat with Claude Code via Discord",
5
5
  "type": "module",
6
6
  "bin": {