@gonzih/cc-discord 0.2.37 → 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";
@@ -211,7 +212,19 @@ function wireStdoutToRedis(proc, ns, wire) {
211
212
  result: resultText,
212
213
  is_error: parsed.is_error ?? false,
213
214
  };
214
- // Do not re-publish result text — assistant event already published it
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
+ }
215
228
  }
216
229
  else {
217
230
  structuredEvent = parsed;
@@ -244,9 +257,12 @@ function wireStdoutToRedis(proc, ns, wire) {
244
257
  });
245
258
  }
246
259
  /**
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).
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.
249
263
  * Returns a Promise that resolves when the process exits.
264
+ *
265
+ * Kept for backwards-compat and direct integration-test usage.
250
266
  */
251
267
  export function spawnSession(ns, message, token, wire) {
252
268
  return new Promise((resolve, reject) => {
@@ -255,15 +271,11 @@ export function spawnSession(ns, message, token, wire) {
255
271
  const env = buildEnv(token);
256
272
  const proc = spawn(claudeBin, [
257
273
  "--continue",
258
- "-p",
274
+ "-p", message,
259
275
  "--output-format", "stream-json",
260
276
  "--verbose",
261
277
  "--dangerously-skip-permissions",
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
- });
278
+ ], { cwd: wsPath, env, stdio: ["ignore", "pipe", "pipe"] });
267
279
  wireStdoutToRedis(proc, ns, wire);
268
280
  proc.on("exit", (code) => {
269
281
  console.log(`[meta-agent-manager] session exited (ns=${ns}, code=${code})`);
@@ -276,75 +288,149 @@ export function spawnSession(ns, message, token, wire) {
276
288
  });
277
289
  }
278
290
  /**
279
- * 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.
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.
280
334
  *
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.
335
+ * On process exit: remove from sessions map. Next message triggers a respawn.
284
336
  *
285
- * Messages queue per namespace and process sequentially no concurrent spawns
286
- * for the same namespace.
337
+ * The 3-second poll loop drains any messages that arrived while a session was
338
+ * starting up or temporarily unavailable.
287
339
  */
288
340
  export function createMetaAgentManager() {
289
341
  let pollInterval = null;
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();
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();
296
346
  /**
297
- * Drain the local queue for a namespace, spawning one session per message.
298
- * 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.
299
349
  */
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)
350
+ function writeToStdin(ns, line) {
351
+ const session = sessions.get(ns);
352
+ if (!session)
308
353
  return;
309
- processing.add(ns);
310
- console.log(`[meta-agent-manager] processQueue started (ns=${ns})`);
311
354
  try {
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
- }
325
- }
355
+ // Interactive mode: plain text line, Claude reads it as user input
356
+ session.proc.stdin.write(`${line}\n`);
326
357
  }
327
- finally {
328
- processing.delete(ns);
329
- 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);
330
385
  }
331
386
  }
332
387
  /**
333
- * 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.
334
391
  */
335
392
  async function ensureSession(ns, repoUrl, token, wire, firstMessage) {
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);
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);
344
433
  }
345
- processQueue(ns).catch((err) => {
346
- console.error(`[meta-agent-manager] processQueue error (ns=${ns}):`, err.message);
347
- });
348
434
  }
349
435
  return {
350
436
  async ensureWorkspace(ns, repoUrl) {
@@ -356,7 +442,7 @@ export function createMetaAgentManager() {
356
442
  },
357
443
  startPolling(wire, getNamespaces, instanceId) {
358
444
  if (pollInterval)
359
- return;
445
+ return; // already running
360
446
  pollInterval = setInterval(() => {
361
447
  const namespaces = getNamespaces();
362
448
  if (namespaces.length === 0)
@@ -371,21 +457,25 @@ export function createMetaAgentManager() {
371
457
  }
372
458
  }).catch(() => { });
373
459
  }
374
- // Skip if already processing
375
- 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
+ });
376
465
  continue;
377
- // Check Redis queue for pending messages
466
+ }
467
+ // Check if anything is queued for this namespace
378
468
  const inputKey = discordMetaInputKey(ns);
379
469
  wire._redis.llen(inputKey).then(async (queueLen) => {
380
470
  if (queueLen === 0)
381
471
  return;
472
+ // Resolve token
382
473
  let token;
383
474
  try {
384
475
  token = await wire.token.getMaster();
385
476
  }
386
477
  catch {
387
- token = sessionMeta.get(ns)?.token
388
- ?? process.env.CLAUDE_CODE_OAUTH_TOKEN
478
+ token = process.env.CLAUDE_CODE_OAUTH_TOKEN
389
479
  ?? process.env.CLAUDE_CODE_TOKEN
390
480
  ?? process.env.ANTHROPIC_API_KEY
391
481
  ?? "";
@@ -394,31 +484,8 @@ export function createMetaAgentManager() {
394
484
  console.warn(`[meta-agent-manager] no token available, skipping session for ns=${ns}`);
395
485
  return;
396
486
  }
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
- });
487
+ // ensureSession will drain the queue itself
488
+ await ensureSession(ns, repoUrl, token, wire);
422
489
  }).catch((err) => {
423
490
  console.warn(`[meta-agent-manager] llen error (ns=${ns}):`, err.message);
424
491
  });
@@ -432,20 +499,35 @@ export function createMetaAgentManager() {
432
499
  pollInterval = null;
433
500
  console.log("[meta-agent-manager] polling stopped");
434
501
  }
435
- 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
+ }
436
514
  },
437
515
  killSession(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})`);
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})`);
441
528
  },
442
529
  sendToSession(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
- });
530
+ writeToStdin(ns, line);
449
531
  },
450
532
  };
451
533
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonzih/cc-discord",
3
- "version": "0.2.37",
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": {