@groupchatai/claude-runner 0.2.3 → 0.4.0

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.
Files changed (2) hide show
  1. package/dist/index.js +211 -75
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -240,11 +240,15 @@ var ALLOWED_TOOLS = [
240
240
  "TodoWrite",
241
241
  "NotebookEdit"
242
242
  ];
243
- function spawnClaudeCode(prompt, config, runOptions) {
243
+ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId) {
244
244
  const format = config.verbose ? "stream-json" : "json";
245
- const args = [
246
- "-p",
247
- prompt,
245
+ const args = [];
246
+ if (resumeSessionId) {
247
+ args.push("--resume", resumeSessionId, "-p", prompt);
248
+ } else {
249
+ args.push("-p", prompt);
250
+ }
251
+ args.push(
248
252
  "--output-format",
249
253
  format,
250
254
  "--verbose",
@@ -252,7 +256,7 @@ function spawnClaudeCode(prompt, config, runOptions) {
252
256
  "default",
253
257
  "--allowedTools",
254
258
  ...ALLOWED_TOOLS
255
- ];
259
+ );
256
260
  const model = runOptions?.model ?? config.model;
257
261
  if (model) {
258
262
  args.push("--model", model);
@@ -263,61 +267,97 @@ function spawnClaudeCode(prompt, config, runOptions) {
263
267
  env: { ...process.env }
264
268
  });
265
269
  const pid = child.pid ?? 0;
266
- const output = new Promise(
267
- (resolve, reject) => {
268
- const chunks = [];
269
- const errChunks = [];
270
- let lineBuf = "";
271
- let lastResultJson = "";
272
- child.stdout?.on("data", (data) => {
273
- chunks.push(data);
274
- if (!config.verbose) return;
275
- lineBuf += data.toString("utf-8");
276
- const lines = lineBuf.split("\n");
277
- lineBuf = lines.pop() ?? "";
278
- for (const line of lines) {
279
- const trimmed = line.trim();
280
- if (!trimmed) continue;
281
- try {
282
- const event = JSON.parse(trimmed);
283
- if (event.type === "result") lastResultJson = trimmed;
270
+ const output = new Promise((resolve, reject) => {
271
+ const chunks = [];
272
+ const errChunks = [];
273
+ let lineBuf = "";
274
+ let lastResultJson = "";
275
+ let capturedSessionId;
276
+ let capturedPrUrl;
277
+ function checkEventForPrUrl(event) {
278
+ if (capturedPrUrl) return;
279
+ const texts = [];
280
+ if (event.message?.content) {
281
+ for (const block of event.message.content) {
282
+ if (block.text) texts.push(block.text);
283
+ }
284
+ }
285
+ if (event.content_block?.text) texts.push(event.content_block.text);
286
+ if (typeof event.result === "string") texts.push(event.result);
287
+ const combined = texts.join("\n");
288
+ const match = combined.match(GITHUB_PR_URL_RE);
289
+ if (match) capturedPrUrl = match[match.length - 1];
290
+ }
291
+ child.stdout?.on("data", (data) => {
292
+ chunks.push(data);
293
+ const text = data.toString("utf-8");
294
+ lineBuf += text;
295
+ const lines = lineBuf.split("\n");
296
+ lineBuf = lines.pop() ?? "";
297
+ for (const line of lines) {
298
+ const trimmed = line.trim();
299
+ if (!trimmed) continue;
300
+ try {
301
+ const event = JSON.parse(trimmed);
302
+ if (event.type === "system" && event.subtype === "init" && event.session_id) {
303
+ capturedSessionId = event.session_id;
304
+ }
305
+ if (event.type === "result") lastResultJson = trimmed;
306
+ checkEventForPrUrl(event);
307
+ if (config.verbose) {
284
308
  const formatted = formatStreamEvent(event, pid);
285
309
  if (formatted) console.log(formatted);
286
- } catch {
310
+ }
311
+ } catch {
312
+ if (config.verbose) {
287
313
  console.log(`${pidTag(pid)} ${C.dim}${trimmed}${C.reset}`);
288
314
  }
289
315
  }
290
- });
291
- child.stderr?.on("data", (data) => {
292
- errChunks.push(data);
293
- if (config.verbose) {
294
- const text = data.toString("utf-8").trimEnd();
295
- if (text) console.error(`${pidTag(pid)} ${C.dim}${text}${C.reset}`);
296
- }
297
- });
298
- child.on("error", (err) => reject(new Error(`Failed to spawn claude: ${err.message}`)));
299
- child.on("close", (code) => {
300
- if (config.verbose && lineBuf.trim()) {
301
- try {
302
- const event = JSON.parse(lineBuf.trim());
303
- if (event.type === "result") lastResultJson = lineBuf.trim();
316
+ }
317
+ });
318
+ child.stderr?.on("data", (data) => {
319
+ errChunks.push(data);
320
+ if (config.verbose) {
321
+ const text = data.toString("utf-8").trimEnd();
322
+ if (text) console.error(`${pidTag(pid)} ${C.dim}${text}${C.reset}`);
323
+ }
324
+ });
325
+ child.on("error", (err) => reject(new Error(`Failed to spawn claude: ${err.message}`)));
326
+ child.on("close", (code) => {
327
+ if (lineBuf.trim()) {
328
+ try {
329
+ const event = JSON.parse(lineBuf.trim());
330
+ if (event.type === "system" && event.subtype === "init" && event.session_id) {
331
+ capturedSessionId = event.session_id;
332
+ }
333
+ if (event.type === "result") lastResultJson = lineBuf.trim();
334
+ checkEventForPrUrl(event);
335
+ if (config.verbose) {
304
336
  const formatted = formatStreamEvent(event, pid);
305
337
  if (formatted) console.log(formatted);
306
- } catch {
338
+ }
339
+ } catch {
340
+ if (config.verbose) {
307
341
  console.log(`${pidTag(pid)} ${C.dim}${lineBuf.trim()}${C.reset}`);
308
342
  }
309
343
  }
310
- const rawOutput = Buffer.concat(chunks).toString("utf-8");
311
- const stdout = config.verbose ? lastResultJson || rawOutput : rawOutput;
312
- const stderr = Buffer.concat(errChunks).toString("utf-8");
313
- if (code !== 0 && stdout.trim().length === 0) {
314
- reject(new Error(`claude exited with code ${code}: ${stderr}`));
315
- } else {
316
- resolve({ stdout, rawOutput, exitCode: code ?? 0 });
317
- }
318
- });
319
- }
320
- );
344
+ }
345
+ const rawOutput = Buffer.concat(chunks).toString("utf-8");
346
+ const stdout = config.verbose ? lastResultJson || rawOutput : rawOutput;
347
+ const stderr = Buffer.concat(errChunks).toString("utf-8");
348
+ if (code !== 0 && stdout.trim().length === 0) {
349
+ reject(new Error(`claude exited with code ${code}: ${stderr}`));
350
+ } else {
351
+ resolve({
352
+ stdout,
353
+ rawOutput,
354
+ exitCode: code ?? 0,
355
+ sessionId: capturedSessionId,
356
+ streamPrUrl: capturedPrUrl
357
+ });
358
+ }
359
+ });
360
+ });
321
361
  return { process: child, output };
322
362
  }
323
363
  function findResultEvent(stdout) {
@@ -404,8 +444,11 @@ function runShellCommand(cmd, args, cwd) {
404
444
  });
405
445
  });
406
446
  }
447
+ var sessionCache = /* @__PURE__ */ new Map();
448
+ var runCounter = 0;
407
449
  async function processRun(client, run, config) {
408
- const runTag = ` ${C.pid}[${run.id.slice(-8)}]${C.reset}`;
450
+ const runNum = ++runCounter;
451
+ const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
409
452
  const log = (msg) => console.log(`${runTag} ${msg}`);
410
453
  const logGreen = (msg) => console.log(`${runTag} ${C.green}${msg}${C.reset}`);
411
454
  const detail = await client.getRunDetail(run.id);
@@ -446,14 +489,41 @@ async function processRun(client, run, config) {
446
489
  });
447
490
  await new Promise((resolve) => git.on("close", () => resolve()));
448
491
  }
492
+ const cachedSessionId = sessionCache.get(run.taskId);
493
+ const isFollowUp = cachedSessionId !== void 0;
494
+ const effectivePrompt = isFollowUp ? detail.prompt : prompt;
495
+ const resumeSession = isFollowUp ? cachedSessionId : void 0;
449
496
  if (config.verbose) {
450
- log(`\u{1F4DD} Prompt (${prompt.length} chars)`);
497
+ if (isFollowUp) {
498
+ log(`\u{1F504} Resuming session ${cachedSessionId.slice(0, 12)}\u2026`);
499
+ }
500
+ log(`\u{1F4DD} Prompt (${effectivePrompt.length} chars)`);
451
501
  if (effectiveModel) log(`\u{1F9E0} Model: ${effectiveModel}`);
452
502
  }
453
- const { process: child, output } = spawnClaudeCode(prompt, config, runOptions);
454
- log(`\u{1F916} Claude Code spawned (pid ${child.pid})`);
455
- const { stdout, rawOutput, exitCode } = await output;
456
- const pullRequestUrl = await detectPullRequestUrl(config.workDir) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
503
+ const { process: child, output } = spawnClaudeCode(
504
+ effectivePrompt,
505
+ config,
506
+ runOptions,
507
+ resumeSession
508
+ );
509
+ log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
510
+ let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
511
+ if (exitCode !== 0 && isFollowUp) {
512
+ log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
513
+ sessionCache.delete(run.taskId);
514
+ const retry = spawnClaudeCode(prompt, config, runOptions);
515
+ log(`\u{1F916} Claude Code spawned (pid ${retry.process.pid}) (fresh)`);
516
+ const retryResult = await retry.output;
517
+ stdout = retryResult.stdout;
518
+ rawOutput = retryResult.rawOutput;
519
+ exitCode = retryResult.exitCode;
520
+ sessionId = retryResult.sessionId;
521
+ streamPrUrl = retryResult.streamPrUrl;
522
+ }
523
+ if (sessionId) {
524
+ sessionCache.set(run.taskId, sessionId);
525
+ }
526
+ const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(config.workDir) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
457
527
  if (exitCode !== 0) {
458
528
  const errorMsg = `Claude Code exited with code ${exitCode}:
459
529
  \`\`\`
@@ -570,23 +640,89 @@ function parseArgs() {
570
640
  async function sleep(ms) {
571
641
  return new Promise((resolve) => setTimeout(resolve, ms));
572
642
  }
573
- function handlePendingRuns(runs, activeRuns, client, config) {
643
+ var TaskScheduler = class {
644
+ slots = /* @__PURE__ */ new Map();
645
+ processing = /* @__PURE__ */ new Set();
646
+ get activeTaskCount() {
647
+ return this.slots.size;
648
+ }
649
+ isRunActive(runId) {
650
+ return this.processing.has(runId);
651
+ }
652
+ hasActiveTask(taskId) {
653
+ return this.slots.has(taskId);
654
+ }
655
+ tryStart(run, maxConcurrent) {
656
+ if (this.processing.has(run.id)) return "at_limit";
657
+ const existingSlot = this.slots.get(run.taskId);
658
+ if (existingSlot) {
659
+ if (!existingSlot.queue.some((q) => q.id === run.id)) {
660
+ existingSlot.queue.push(run);
661
+ }
662
+ return "queued";
663
+ }
664
+ if (this.slots.size >= maxConcurrent) {
665
+ return "at_limit";
666
+ }
667
+ this.slots.set(run.taskId, { activeRunId: run.id, queue: [] });
668
+ this.processing.add(run.id);
669
+ return "start";
670
+ }
671
+ complete(run) {
672
+ this.processing.delete(run.id);
673
+ const slot = this.slots.get(run.taskId);
674
+ if (!slot) return void 0;
675
+ const next = slot.queue.shift();
676
+ if (next) {
677
+ slot.activeRunId = next.id;
678
+ this.processing.add(next.id);
679
+ return next;
680
+ }
681
+ this.slots.delete(run.taskId);
682
+ return void 0;
683
+ }
684
+ isEmpty() {
685
+ return this.slots.size === 0;
686
+ }
687
+ };
688
+ function handlePendingRuns(runs, scheduler, client, config) {
574
689
  if (runs.length > 0 && config.verbose) {
575
690
  console.log(`\u{1F4EC} ${runs.length} pending run(s)`);
576
691
  }
577
692
  for (const run of runs) {
578
- if (activeRuns.has(run.id)) continue;
579
- if (activeRuns.size >= config.maxConcurrent) {
693
+ const result = scheduler.tryStart(run, config.maxConcurrent);
694
+ if (result === "at_limit") {
580
695
  if (config.verbose) {
581
- console.log(`\u23F8 At concurrency limit (${config.maxConcurrent}), waiting\u2026`);
696
+ console.log(`\u23F8 At concurrency limit (${config.maxConcurrent} tasks), waiting\u2026`);
582
697
  }
583
698
  break;
584
699
  }
585
- activeRuns.add(run.id);
586
- processRun(client, run, config).catch((err) => console.error(`Unhandled error processing run ${run.id}:`, err)).finally(() => activeRuns.delete(run.id));
700
+ if (result === "queued") {
701
+ if (config.verbose) {
702
+ console.log(` ${C.dim}Queued follow-up for active task${C.reset}`);
703
+ }
704
+ continue;
705
+ }
706
+ processRunWithDrain(client, run, scheduler, config).catch(
707
+ (err) => console.error(`Unhandled error processing run ${run.id}:`, err)
708
+ );
709
+ }
710
+ }
711
+ async function processRunWithDrain(client, run, scheduler, config) {
712
+ let current = run;
713
+ while (current) {
714
+ try {
715
+ await processRun(client, current, config);
716
+ } catch (err) {
717
+ console.error(`Unhandled error processing run ${current.id}:`, err);
718
+ }
719
+ current = scheduler.complete(current);
720
+ if (current) {
721
+ console.log(` \u23ED Processing queued follow-up\u2026`);
722
+ }
587
723
  }
588
724
  }
589
- async function runWithWebSocket(client, config, activeRuns) {
725
+ async function runWithWebSocket(client, config, scheduler) {
590
726
  let ConvexClient;
591
727
  let anyApi;
592
728
  try {
@@ -595,7 +731,7 @@ async function runWithWebSocket(client, config, activeRuns) {
595
731
  } catch {
596
732
  console.warn("\u26A0 convex package not found \u2014 falling back to HTTP polling.");
597
733
  console.warn(" Install convex for WebSocket mode: npm i convex\n");
598
- await runWithPolling(client, config, activeRuns);
734
+ await runWithPolling(client, config, scheduler);
599
735
  return;
600
736
  }
601
737
  const convex = new ConvexClient(config.convexUrl);
@@ -605,7 +741,7 @@ async function runWithWebSocket(client, config, activeRuns) {
605
741
  { token: config.token },
606
742
  (runs) => {
607
743
  if (!runs || runs.length === 0) return;
608
- handlePendingRuns(runs, activeRuns, client, config);
744
+ handlePendingRuns(runs, scheduler, client, config);
609
745
  }
610
746
  );
611
747
  await new Promise((resolve) => {
@@ -618,7 +754,7 @@ async function runWithWebSocket(client, config, activeRuns) {
618
754
  process.on("SIGTERM", shutdown);
619
755
  });
620
756
  }
621
- async function runWithPolling(client, config, activeRuns) {
757
+ async function runWithPolling(client, config, scheduler) {
622
758
  console.log(`\u{1F4E1} Polling every ${config.pollInterval}ms \u2014 listening for tasks\u2026
623
759
  `);
624
760
  let running = true;
@@ -631,17 +767,17 @@ async function runWithPolling(client, config, activeRuns) {
631
767
  if (config.once) {
632
768
  try {
633
769
  const pending = await client.listPendingRuns();
634
- handlePendingRuns(pending, activeRuns, client, config);
770
+ handlePendingRuns(pending, scheduler, client, config);
635
771
  } catch (err) {
636
772
  console.error(`Poll error: ${err instanceof Error ? err.message : err}`);
637
773
  }
638
- while (activeRuns.size > 0) await sleep(1e3);
774
+ while (!scheduler.isEmpty()) await sleep(1e3);
639
775
  return;
640
776
  }
641
777
  while (running) {
642
778
  try {
643
779
  const pending = await client.listPendingRuns();
644
- handlePendingRuns(pending, activeRuns, client, config);
780
+ handlePendingRuns(pending, scheduler, client, config);
645
781
  } catch (err) {
646
782
  const msg = err instanceof Error ? err.message : String(err);
647
783
  if (config.verbose || !msg.includes("fetch")) {
@@ -670,15 +806,15 @@ async function main() {
670
806
  if (config.model) console.log(` Model: ${config.model}`);
671
807
  if (config.dryRun) console.log(` Mode: DRY RUN`);
672
808
  console.log();
673
- const activeRuns = /* @__PURE__ */ new Set();
809
+ const scheduler = new TaskScheduler();
674
810
  if (config.poll || config.once) {
675
- await runWithPolling(client, config, activeRuns);
811
+ await runWithPolling(client, config, scheduler);
676
812
  } else {
677
- await runWithWebSocket(client, config, activeRuns);
813
+ await runWithWebSocket(client, config, scheduler);
678
814
  }
679
- if (activeRuns.size > 0) {
680
- console.log(`\u23F3 Waiting for ${activeRuns.size} in-flight run(s)\u2026`);
681
- while (activeRuns.size > 0) {
815
+ if (!scheduler.isEmpty()) {
816
+ console.log(`\u23F3 Waiting for ${scheduler.activeTaskCount} in-flight task(s)\u2026`);
817
+ while (!scheduler.isEmpty()) {
682
818
  await sleep(1e3);
683
819
  }
684
820
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {