@groupchatai/claude-runner 0.3.1 → 0.4.1

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 +368 -34
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { spawn } from "child_process";
5
- import { readFileSync } from "fs";
4
+ import { spawn, execFileSync } from "child_process";
5
+ import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, rmSync } from "fs";
6
6
  import path from "path";
7
7
  var API_URL = "https://groupchat.ai";
8
8
  var CONVEX_URL = "https://fantastic-jay-464.convex.cloud";
@@ -116,7 +116,7 @@ Due: ${new Date(detail.task.dueDate).toLocaleDateString()}`);
116
116
  "- NEVER run `gh pr merge`. Do NOT merge any PR.",
117
117
  "- NEVER run `gh pr close`. Do NOT close any PR.",
118
118
  "- NEVER run `git push --force` or `git push -f`. No force pushes.",
119
- "- When you are done, provide a clear summary of what you accomplished including the PR URL and branch name."
119
+ "- When you are done, provide a clear summary of what you accomplished."
120
120
  ].join("\n")
121
121
  );
122
122
  return parts.join("\n");
@@ -240,7 +240,7 @@ var ALLOWED_TOOLS = [
240
240
  "TodoWrite",
241
241
  "NotebookEdit"
242
242
  ];
243
- function spawnClaudeCode(prompt, config, runOptions, resumeSessionId) {
243
+ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
244
244
  const format = config.verbose ? "stream-json" : "json";
245
245
  const args = [];
246
246
  if (resumeSessionId) {
@@ -262,9 +262,8 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId) {
262
262
  args.push("--model", model);
263
263
  }
264
264
  const child = spawn("claude", args, {
265
- cwd: config.workDir,
266
- stdio: ["ignore", "pipe", "pipe"],
267
- env: { ...process.env }
265
+ cwd: cwdOverride ?? config.workDir,
266
+ stdio: ["ignore", "pipe", "pipe"]
268
267
  });
269
268
  const pid = child.pid ?? 0;
270
269
  const output = new Promise((resolve, reject) => {
@@ -445,8 +444,205 @@ function runShellCommand(cmd, args, cwd) {
445
444
  });
446
445
  }
447
446
  var sessionCache = /* @__PURE__ */ new Map();
448
- async function processRun(client, run, config) {
449
- const runTag = ` ${C.pid}[${run.id.slice(-8)}]${C.reset}`;
447
+ var runCounter = 0;
448
+ var WORKTREE_DIR = ".agent-worktrees";
449
+ var WORKTREE_PREFIX = "task-";
450
+ function worktreeNameForTask(taskId) {
451
+ return `${WORKTREE_PREFIX}${taskId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(-12)}`;
452
+ }
453
+ function execGit(args, cwd) {
454
+ return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: "pipe" }).trim();
455
+ }
456
+ function getDefaultBranch(repoDir) {
457
+ try {
458
+ const out = execGit(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], repoDir);
459
+ return out.replace("origin/", "");
460
+ } catch {
461
+ return "main";
462
+ }
463
+ }
464
+ function createWorktree(repoDir, taskId) {
465
+ const name = worktreeNameForTask(taskId);
466
+ const branchName = `agent/${name}-${Date.now()}`;
467
+ const worktreeBase = path.join(repoDir, WORKTREE_DIR);
468
+ const worktreePath = path.join(worktreeBase, name);
469
+ if (existsSync(worktreePath)) {
470
+ return worktreePath;
471
+ }
472
+ const baseBranch = getDefaultBranch(repoDir);
473
+ try {
474
+ execGit(["fetch", "origin", baseBranch, "--quiet"], repoDir);
475
+ } catch {
476
+ }
477
+ execGit(["worktree", "add", "-b", branchName, worktreePath, `origin/${baseBranch}`], repoDir);
478
+ writeFileSync(path.join(worktreePath, ".agent-branch"), branchName, "utf-8");
479
+ return worktreePath;
480
+ }
481
+ async function listOurWorktrees(workDir) {
482
+ const worktreeDir = path.join(workDir, WORKTREE_DIR);
483
+ let entries;
484
+ try {
485
+ entries = readdirSync(worktreeDir).filter((e) => e.startsWith(WORKTREE_PREFIX));
486
+ } catch {
487
+ return [];
488
+ }
489
+ const results = [];
490
+ for (const name of entries) {
491
+ const wtPath = path.join(worktreeDir, name);
492
+ let branch = `agent/${name}`;
493
+ try {
494
+ branch = readFileSync(path.join(wtPath, ".agent-branch"), "utf-8").trim();
495
+ } catch {
496
+ }
497
+ let ageMs = 0;
498
+ let age = "unknown";
499
+ try {
500
+ const stat = statSync(wtPath);
501
+ ageMs = Date.now() - stat.mtimeMs;
502
+ if (ageMs < 6e4) age = "just now";
503
+ else if (ageMs < 36e5) age = `${Math.floor(ageMs / 6e4)}m ago`;
504
+ else if (ageMs < 864e5) age = `${Math.floor(ageMs / 36e5)}h ago`;
505
+ else age = `${Math.floor(ageMs / 864e5)}d ago`;
506
+ } catch {
507
+ }
508
+ let isMerged = false;
509
+ try {
510
+ const merged = await runShellCommand(
511
+ "git",
512
+ ["branch", "--merged", "main", "--list", branch],
513
+ workDir
514
+ );
515
+ isMerged = merged.trim().length > 0;
516
+ } catch {
517
+ }
518
+ let hasUnpushed = false;
519
+ try {
520
+ const log = await runShellCommand(
521
+ "git",
522
+ ["log", `origin/${branch}..${branch}`, "--oneline"],
523
+ wtPath
524
+ );
525
+ hasUnpushed = log.trim().length > 0;
526
+ } catch {
527
+ try {
528
+ const log = await runShellCommand(
529
+ "git",
530
+ ["log", `origin/main..${branch}`, "--oneline"],
531
+ workDir
532
+ );
533
+ hasUnpushed = log.trim().length > 0;
534
+ } catch {
535
+ }
536
+ }
537
+ results.push({
538
+ name,
539
+ path: wtPath,
540
+ branch,
541
+ age,
542
+ ageMs,
543
+ isMerged,
544
+ hasUnpushed: hasUnpushed && !isMerged,
545
+ safeToRemove: isMerged || !hasUnpushed && ageMs > 36e5
546
+ });
547
+ }
548
+ return results;
549
+ }
550
+ async function removeWorktree(workDir, info) {
551
+ try {
552
+ await runShellCommand("git", ["worktree", "remove", info.path, "--force"], workDir);
553
+ try {
554
+ await runShellCommand("git", ["branch", "-D", info.branch], workDir);
555
+ } catch {
556
+ }
557
+ return true;
558
+ } catch (err) {
559
+ console.error(` Failed to remove ${info.name}: ${err instanceof Error ? err.message : err}`);
560
+ return false;
561
+ }
562
+ }
563
+ function removeWorktreeSimple(repoDir, worktreePath) {
564
+ let branchName;
565
+ try {
566
+ branchName = readFileSync(path.join(worktreePath, ".agent-branch"), "utf-8").trim();
567
+ } catch {
568
+ }
569
+ try {
570
+ execGit(["worktree", "remove", worktreePath, "--force"], repoDir);
571
+ } catch {
572
+ try {
573
+ if (existsSync(worktreePath)) rmSync(worktreePath, { recursive: true, force: true });
574
+ execGit(["worktree", "prune"], repoDir);
575
+ } catch {
576
+ }
577
+ }
578
+ if (branchName) {
579
+ try {
580
+ execGit(["branch", "-D", branchName], repoDir);
581
+ } catch {
582
+ }
583
+ }
584
+ }
585
+ async function startupSweep(workDir) {
586
+ const worktrees = await listOurWorktrees(workDir);
587
+ if (worktrees.length === 0) return;
588
+ const safe = worktrees.filter((w) => w.safeToRemove);
589
+ const unsafe = worktrees.filter((w) => !w.safeToRemove);
590
+ if (safe.length > 0) {
591
+ console.log(`\u{1F9F9} Cleaning up ${safe.length} stale worktree(s)\u2026`);
592
+ for (const wt of safe) {
593
+ const label = wt.isMerged ? "merged" : "stale";
594
+ const ok = await removeWorktree(workDir, wt);
595
+ if (ok) console.log(` \u2705 Removed ${wt.name} (${label}, ${wt.age})`);
596
+ }
597
+ }
598
+ if (unsafe.length > 0) {
599
+ console.log(` \u26A0\uFE0F ${unsafe.length} worktree(s) with unmerged changes kept:`);
600
+ for (const wt of unsafe) {
601
+ console.log(` ${wt.name} (${wt.age})`);
602
+ }
603
+ console.log(` Run "npx @groupchatai/claude-runner cleanup" to manage them.
604
+ `);
605
+ }
606
+ }
607
+ async function interactiveCleanup(workDir) {
608
+ const worktrees = await listOurWorktrees(workDir);
609
+ if (worktrees.length === 0) {
610
+ console.log("\n\u{1F33F} No agent worktrees found.\n");
611
+ return;
612
+ }
613
+ console.log(`
614
+ \u{1F33F} Found ${worktrees.length} worktree(s):
615
+ `);
616
+ for (const wt of worktrees) {
617
+ const icon = wt.safeToRemove ? "\u2705" : "\u26A0\uFE0F ";
618
+ const status = wt.isMerged ? "Branch merged" : wt.hasUnpushed ? "Branch NOT merged, has unpushed commits" : "Branch NOT merged";
619
+ const safety = wt.safeToRemove ? "Safe to remove" : "Has unmerged work";
620
+ console.log(` ${icon} ${wt.name} (${wt.branch})`);
621
+ console.log(` ${status} \u2022 Created ${wt.age} \u2022 ${safety}`);
622
+ }
623
+ const safe = worktrees.filter((w) => w.safeToRemove);
624
+ const unsafe = worktrees.filter((w) => !w.safeToRemove);
625
+ if (safe.length > 0) {
626
+ console.log(`
627
+ Removing ${safe.length} safe worktree(s)\u2026`);
628
+ for (const wt of safe) {
629
+ const ok = await removeWorktree(workDir, wt);
630
+ if (ok) console.log(` \u2705 Removed ${wt.name}`);
631
+ }
632
+ }
633
+ if (unsafe.length > 0) {
634
+ console.log(`
635
+ \u26A0\uFE0F ${unsafe.length} worktree(s) with unmerged work:`);
636
+ for (const wt of unsafe) {
637
+ console.log(` Skipping ${wt.name} \u2014 has unmerged changes`);
638
+ console.log(` To force remove: git worktree remove "${wt.path}" --force`);
639
+ }
640
+ }
641
+ console.log();
642
+ }
643
+ async function processRun(client, run, config, worktreeDir) {
644
+ const runNum = ++runCounter;
645
+ const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
450
646
  const log = (msg) => console.log(`${runTag} ${msg}`);
451
647
  const logGreen = (msg) => console.log(`${runTag} ${C.green}${msg}${C.reset}`);
452
648
  const detail = await client.getRunDetail(run.id);
@@ -478,11 +674,12 @@ async function processRun(client, run, config) {
478
674
  throw err;
479
675
  }
480
676
  log("\u25B6 Run started");
677
+ const effectiveCwd = worktreeDir ?? config.workDir;
481
678
  try {
482
- if (runOptions.branch) {
679
+ if (!worktreeDir && runOptions.branch) {
483
680
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
484
681
  const git = spawn("git", ["checkout", runOptions.branch], {
485
- cwd: config.workDir,
682
+ cwd: effectiveCwd,
486
683
  stdio: "pipe"
487
684
  });
488
685
  await new Promise((resolve) => git.on("close", () => resolve()));
@@ -502,14 +699,15 @@ async function processRun(client, run, config) {
502
699
  effectivePrompt,
503
700
  config,
504
701
  runOptions,
505
- resumeSession
702
+ resumeSession,
703
+ effectiveCwd
506
704
  );
507
705
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
508
706
  let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
509
707
  if (exitCode !== 0 && isFollowUp) {
510
708
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
511
709
  sessionCache.delete(run.taskId);
512
- const retry = spawnClaudeCode(prompt, config, runOptions);
710
+ const retry = spawnClaudeCode(prompt, config, runOptions, void 0, effectiveCwd);
513
711
  log(`\u{1F916} Claude Code spawned (pid ${retry.process.pid}) (fresh)`);
514
712
  const retryResult = await retry.output;
515
713
  stdout = retryResult.stdout;
@@ -521,7 +719,7 @@ async function processRun(client, run, config) {
521
719
  if (sessionId) {
522
720
  sessionCache.set(run.taskId, sessionId);
523
721
  }
524
- const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(config.workDir) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
722
+ const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(effectiveCwd) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
525
723
  if (exitCode !== 0) {
526
724
  const errorMsg = `Claude Code exited with code ${exitCode}:
527
725
  \`\`\`
@@ -574,6 +772,34 @@ function loadEnvFile() {
574
772
  }
575
773
  }
576
774
  }
775
+ function showHelp() {
776
+ console.log(`
777
+ Usage: npx @groupchatai/claude-runner [command] [options]
778
+
779
+ Commands:
780
+ (default) Start the agent runner
781
+ cleanup Interactively review and remove stale worktrees
782
+
783
+ Options:
784
+ --work-dir <path> Repo directory for Claude Code to work in (default: cwd)
785
+ --poll Use HTTP polling instead of WebSocket (default: websocket)
786
+ --poll-interval <ms> Polling interval in milliseconds (default: 5000, only with --poll)
787
+ --max-concurrent <n> Max concurrent tasks (default: 1)
788
+ --model <model> Claude model to use (passed to claude CLI)
789
+ --dry-run Poll and log runs without executing Claude Code
790
+ --once Process one batch of pending runs and exit (implies --poll)
791
+ --verbose Stream Claude Code activity with pid
792
+ --token <token> Agent token (or set GCA_TOKEN env var)
793
+ --no-worktree Disable git worktree isolation for runs
794
+ -h, --help Show this help message
795
+
796
+ Environment variables:
797
+ GCA_TOKEN Agent token (gca_...)
798
+ GCA_API_URL API URL override
799
+ GCA_CONVEX_URL Convex URL override
800
+ `);
801
+ process.exit(0);
802
+ }
577
803
  function parseArgs() {
578
804
  loadEnvFile();
579
805
  const args = process.argv.slice(2);
@@ -587,7 +813,8 @@ function parseArgs() {
587
813
  poll: false,
588
814
  dryRun: false,
589
815
  once: false,
590
- verbose: false
816
+ verbose: false,
817
+ useWorktrees: true
591
818
  };
592
819
  for (let i = 0; i < args.length; i++) {
593
820
  const arg = args[i];
@@ -619,6 +846,15 @@ function parseArgs() {
619
846
  case "--token":
620
847
  config.token = args[++i] ?? "";
621
848
  break;
849
+ case "--help":
850
+ case "-h":
851
+ showHelp();
852
+ break;
853
+ case "--no-worktree":
854
+ config.useWorktrees = false;
855
+ break;
856
+ case "cleanup":
857
+ break;
622
858
  default:
623
859
  console.error(`Unknown argument: ${arg}`);
624
860
  process.exit(1);
@@ -638,23 +874,109 @@ function parseArgs() {
638
874
  async function sleep(ms) {
639
875
  return new Promise((resolve) => setTimeout(resolve, ms));
640
876
  }
641
- function handlePendingRuns(runs, activeRuns, client, config) {
877
+ var TaskScheduler = class {
878
+ slots = /* @__PURE__ */ new Map();
879
+ processing = /* @__PURE__ */ new Set();
880
+ get activeTaskCount() {
881
+ return this.slots.size;
882
+ }
883
+ isRunActive(runId) {
884
+ return this.processing.has(runId);
885
+ }
886
+ hasActiveTask(taskId) {
887
+ return this.slots.has(taskId);
888
+ }
889
+ tryStart(run, maxConcurrent) {
890
+ if (this.processing.has(run.id)) return "at_limit";
891
+ const existingSlot = this.slots.get(run.taskId);
892
+ if (existingSlot) {
893
+ if (!existingSlot.queue.some((q) => q.id === run.id)) {
894
+ existingSlot.queue.push(run);
895
+ }
896
+ return "queued";
897
+ }
898
+ if (this.slots.size >= maxConcurrent) {
899
+ return "at_limit";
900
+ }
901
+ this.slots.set(run.taskId, { activeRunId: run.id, queue: [] });
902
+ this.processing.add(run.id);
903
+ return "start";
904
+ }
905
+ complete(run) {
906
+ this.processing.delete(run.id);
907
+ const slot = this.slots.get(run.taskId);
908
+ if (!slot) return void 0;
909
+ const next = slot.queue.shift();
910
+ if (next) {
911
+ slot.activeRunId = next.id;
912
+ this.processing.add(next.id);
913
+ return next;
914
+ }
915
+ this.slots.delete(run.taskId);
916
+ return void 0;
917
+ }
918
+ isEmpty() {
919
+ return this.slots.size === 0;
920
+ }
921
+ };
922
+ function handlePendingRuns(runs, scheduler, client, config) {
642
923
  if (runs.length > 0 && config.verbose) {
643
924
  console.log(`\u{1F4EC} ${runs.length} pending run(s)`);
644
925
  }
645
926
  for (const run of runs) {
646
- if (activeRuns.has(run.id)) continue;
647
- if (activeRuns.size >= config.maxConcurrent) {
927
+ const result = scheduler.tryStart(run, config.maxConcurrent);
928
+ if (result === "at_limit") {
648
929
  if (config.verbose) {
649
- console.log(`\u23F8 At concurrency limit (${config.maxConcurrent}), waiting\u2026`);
930
+ console.log(`\u23F8 At concurrency limit (${config.maxConcurrent} tasks), waiting\u2026`);
650
931
  }
651
932
  break;
652
933
  }
653
- activeRuns.add(run.id);
654
- processRun(client, run, config).catch((err) => console.error(`Unhandled error processing run ${run.id}:`, err)).finally(() => activeRuns.delete(run.id));
934
+ if (result === "queued") {
935
+ if (config.verbose) {
936
+ console.log(` ${C.dim}Queued follow-up for active task${C.reset}`);
937
+ }
938
+ continue;
939
+ }
940
+ processRunWithDrain(client, run, scheduler, config).catch(
941
+ (err) => console.error(`Unhandled error processing run ${run.id}:`, err)
942
+ );
943
+ }
944
+ }
945
+ async function processRunWithDrain(client, run, scheduler, config) {
946
+ let worktreeDir;
947
+ if (config.useWorktrees) {
948
+ try {
949
+ worktreeDir = createWorktree(config.workDir, run.taskId);
950
+ console.log(
951
+ ` \u{1F333} Worktree created from main \u2192 ${path.relative(config.workDir, worktreeDir)}`
952
+ );
953
+ } catch (err) {
954
+ console.error(` \u26A0 Failed to create worktree, running in-place:`, err);
955
+ }
956
+ }
957
+ let current = run;
958
+ while (current) {
959
+ try {
960
+ await processRun(client, current, config, worktreeDir);
961
+ } catch (err) {
962
+ console.error(`Unhandled error processing run ${current.id}:`, err);
963
+ }
964
+ current = scheduler.complete(current);
965
+ if (current) {
966
+ console.log(` \u23ED Processing queued follow-up\u2026`);
967
+ }
968
+ }
969
+ if (worktreeDir) {
970
+ sessionCache.delete(run.taskId);
971
+ try {
972
+ removeWorktreeSimple(config.workDir, worktreeDir);
973
+ console.log(` \u{1F9F9} Worktree cleaned up`);
974
+ } catch (err) {
975
+ console.error(` \u26A0 Failed to clean up worktree:`, err);
976
+ }
655
977
  }
656
978
  }
657
- async function runWithWebSocket(client, config, activeRuns) {
979
+ async function runWithWebSocket(client, config, scheduler) {
658
980
  let ConvexClient;
659
981
  let anyApi;
660
982
  try {
@@ -663,7 +985,7 @@ async function runWithWebSocket(client, config, activeRuns) {
663
985
  } catch {
664
986
  console.warn("\u26A0 convex package not found \u2014 falling back to HTTP polling.");
665
987
  console.warn(" Install convex for WebSocket mode: npm i convex\n");
666
- await runWithPolling(client, config, activeRuns);
988
+ await runWithPolling(client, config, scheduler);
667
989
  return;
668
990
  }
669
991
  const convex = new ConvexClient(config.convexUrl);
@@ -673,7 +995,7 @@ async function runWithWebSocket(client, config, activeRuns) {
673
995
  { token: config.token },
674
996
  (runs) => {
675
997
  if (!runs || runs.length === 0) return;
676
- handlePendingRuns(runs, activeRuns, client, config);
998
+ handlePendingRuns(runs, scheduler, client, config);
677
999
  }
678
1000
  );
679
1001
  await new Promise((resolve) => {
@@ -686,7 +1008,7 @@ async function runWithWebSocket(client, config, activeRuns) {
686
1008
  process.on("SIGTERM", shutdown);
687
1009
  });
688
1010
  }
689
- async function runWithPolling(client, config, activeRuns) {
1011
+ async function runWithPolling(client, config, scheduler) {
690
1012
  console.log(`\u{1F4E1} Polling every ${config.pollInterval}ms \u2014 listening for tasks\u2026
691
1013
  `);
692
1014
  let running = true;
@@ -699,17 +1021,17 @@ async function runWithPolling(client, config, activeRuns) {
699
1021
  if (config.once) {
700
1022
  try {
701
1023
  const pending = await client.listPendingRuns();
702
- handlePendingRuns(pending, activeRuns, client, config);
1024
+ handlePendingRuns(pending, scheduler, client, config);
703
1025
  } catch (err) {
704
1026
  console.error(`Poll error: ${err instanceof Error ? err.message : err}`);
705
1027
  }
706
- while (activeRuns.size > 0) await sleep(1e3);
1028
+ while (!scheduler.isEmpty()) await sleep(1e3);
707
1029
  return;
708
1030
  }
709
1031
  while (running) {
710
1032
  try {
711
1033
  const pending = await client.listPendingRuns();
712
- handlePendingRuns(pending, activeRuns, client, config);
1034
+ handlePendingRuns(pending, scheduler, client, config);
713
1035
  } catch (err) {
714
1036
  const msg = err instanceof Error ? err.message : String(err);
715
1037
  if (config.verbose || !msg.includes("fetch")) {
@@ -720,6 +1042,14 @@ async function runWithPolling(client, config, activeRuns) {
720
1042
  }
721
1043
  }
722
1044
  async function main() {
1045
+ if (process.argv.includes("cleanup")) {
1046
+ loadEnvFile();
1047
+ const workDir = process.cwd();
1048
+ const workDirIdx = process.argv.indexOf("--work-dir");
1049
+ const resolvedDir = workDirIdx >= 0 ? path.resolve(process.argv[workDirIdx + 1] ?? ".") : workDir;
1050
+ await interactiveCleanup(resolvedDir);
1051
+ return;
1052
+ }
723
1053
  const config = parseArgs();
724
1054
  const client = new GroupChatAgentClient(config.apiUrl, config.token);
725
1055
  let me;
@@ -737,16 +1067,20 @@ async function main() {
737
1067
  if (config.apiUrl !== API_URL) console.log(` API: ${config.apiUrl}`);
738
1068
  if (config.model) console.log(` Model: ${config.model}`);
739
1069
  if (config.dryRun) console.log(` Mode: DRY RUN`);
1070
+ if (!config.useWorktrees) console.log(` Worktrees: disabled`);
740
1071
  console.log();
741
- const activeRuns = /* @__PURE__ */ new Set();
1072
+ if (config.useWorktrees) {
1073
+ await startupSweep(config.workDir);
1074
+ }
1075
+ const scheduler = new TaskScheduler();
742
1076
  if (config.poll || config.once) {
743
- await runWithPolling(client, config, activeRuns);
1077
+ await runWithPolling(client, config, scheduler);
744
1078
  } else {
745
- await runWithWebSocket(client, config, activeRuns);
1079
+ await runWithWebSocket(client, config, scheduler);
746
1080
  }
747
- if (activeRuns.size > 0) {
748
- console.log(`\u23F3 Waiting for ${activeRuns.size} in-flight run(s)\u2026`);
749
- while (activeRuns.size > 0) {
1081
+ if (!scheduler.isEmpty()) {
1082
+ console.log(`\u23F3 Waiting for ${scheduler.activeTaskCount} in-flight task(s)\u2026`);
1083
+ while (!scheduler.isEmpty()) {
750
1084
  await sleep(1e3);
751
1085
  }
752
1086
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {