@groupchatai/claude-runner 0.4.0 → 0.4.2

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 +293 -20
  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) => {
@@ -446,7 +445,202 @@ function runShellCommand(cmd, args, cwd) {
446
445
  }
447
446
  var sessionCache = /* @__PURE__ */ new Map();
448
447
  var runCounter = 0;
449
- async function processRun(client, run, config) {
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) {
450
644
  const runNum = ++runCounter;
451
645
  const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
452
646
  const log = (msg) => console.log(`${runTag} ${msg}`);
@@ -480,11 +674,12 @@ async function processRun(client, run, config) {
480
674
  throw err;
481
675
  }
482
676
  log("\u25B6 Run started");
677
+ const effectiveCwd = worktreeDir ?? config.workDir;
483
678
  try {
484
- if (runOptions.branch) {
679
+ if (!worktreeDir && runOptions.branch) {
485
680
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
486
681
  const git = spawn("git", ["checkout", runOptions.branch], {
487
- cwd: config.workDir,
682
+ cwd: effectiveCwd,
488
683
  stdio: "pipe"
489
684
  });
490
685
  await new Promise((resolve) => git.on("close", () => resolve()));
@@ -504,14 +699,15 @@ async function processRun(client, run, config) {
504
699
  effectivePrompt,
505
700
  config,
506
701
  runOptions,
507
- resumeSession
702
+ resumeSession,
703
+ effectiveCwd
508
704
  );
509
705
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
510
706
  let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
511
707
  if (exitCode !== 0 && isFollowUp) {
512
708
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
513
709
  sessionCache.delete(run.taskId);
514
- const retry = spawnClaudeCode(prompt, config, runOptions);
710
+ const retry = spawnClaudeCode(prompt, config, runOptions, void 0, effectiveCwd);
515
711
  log(`\u{1F916} Claude Code spawned (pid ${retry.process.pid}) (fresh)`);
516
712
  const retryResult = await retry.output;
517
713
  stdout = retryResult.stdout;
@@ -523,7 +719,7 @@ async function processRun(client, run, config) {
523
719
  if (sessionId) {
524
720
  sessionCache.set(run.taskId, sessionId);
525
721
  }
526
- const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(config.workDir) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
722
+ const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(effectiveCwd) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
527
723
  if (exitCode !== 0) {
528
724
  const errorMsg = `Claude Code exited with code ${exitCode}:
529
725
  \`\`\`
@@ -576,6 +772,34 @@ function loadEnvFile() {
576
772
  }
577
773
  }
578
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 fallback (default: websocket, see below)
786
+ --poll-interval <ms> Polling interval in milliseconds (default: 30000, only with --poll)
787
+ --max-concurrent <n> Max concurrent tasks (default: 5)
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
+ }
579
803
  function parseArgs() {
580
804
  loadEnvFile();
581
805
  const args = process.argv.slice(2);
@@ -584,12 +808,13 @@ function parseArgs() {
584
808
  apiUrl: process.env.GCA_API_URL ?? API_URL,
585
809
  convexUrl: process.env.GCA_CONVEX_URL ?? CONVEX_URL,
586
810
  workDir: process.cwd(),
587
- pollInterval: 5e3,
588
- maxConcurrent: 1,
811
+ pollInterval: 3e4,
812
+ maxConcurrent: 5,
589
813
  poll: false,
590
814
  dryRun: false,
591
815
  once: false,
592
- verbose: false
816
+ verbose: false,
817
+ useWorktrees: true
593
818
  };
594
819
  for (let i = 0; i < args.length; i++) {
595
820
  const arg = args[i];
@@ -598,7 +823,7 @@ function parseArgs() {
598
823
  config.workDir = path.resolve(args[++i] ?? ".");
599
824
  break;
600
825
  case "--poll-interval":
601
- config.pollInterval = parseInt(args[++i] ?? "5000", 10);
826
+ config.pollInterval = parseInt(args[++i] ?? "30000", 10);
602
827
  break;
603
828
  case "--max-concurrent":
604
829
  config.maxConcurrent = parseInt(args[++i] ?? "1", 10);
@@ -621,6 +846,15 @@ function parseArgs() {
621
846
  case "--token":
622
847
  config.token = args[++i] ?? "";
623
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;
624
858
  default:
625
859
  console.error(`Unknown argument: ${arg}`);
626
860
  process.exit(1);
@@ -709,10 +943,21 @@ function handlePendingRuns(runs, scheduler, client, config) {
709
943
  }
710
944
  }
711
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
+ }
712
957
  let current = run;
713
958
  while (current) {
714
959
  try {
715
- await processRun(client, current, config);
960
+ await processRun(client, current, config, worktreeDir);
716
961
  } catch (err) {
717
962
  console.error(`Unhandled error processing run ${current.id}:`, err);
718
963
  }
@@ -721,6 +966,15 @@ async function processRunWithDrain(client, run, scheduler, config) {
721
966
  console.log(` \u23ED Processing queued follow-up\u2026`);
722
967
  }
723
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
+ }
977
+ }
724
978
  }
725
979
  async function runWithWebSocket(client, config, scheduler) {
726
980
  let ConvexClient;
@@ -730,6 +984,7 @@ async function runWithWebSocket(client, config, scheduler) {
730
984
  ({ anyApi } = await import("convex/server"));
731
985
  } catch {
732
986
  console.warn("\u26A0 convex package not found \u2014 falling back to HTTP polling.");
987
+ console.warn(" WebSocket mode is recommended for instant task pickup and zero idle cost.");
733
988
  console.warn(" Install convex for WebSocket mode: npm i convex\n");
734
989
  await runWithPolling(client, config, scheduler);
735
990
  return;
@@ -755,8 +1010,14 @@ async function runWithWebSocket(client, config, scheduler) {
755
1010
  });
756
1011
  }
757
1012
  async function runWithPolling(client, config, scheduler) {
758
- console.log(`\u{1F4E1} Polling every ${config.pollInterval}ms \u2014 listening for tasks\u2026
759
- `);
1013
+ console.log(`\u{1F4E1} Polling every ${config.pollInterval / 1e3}s \u2014 listening for tasks\u2026`);
1014
+ console.log(
1015
+ `${C.dim} Tip: WebSocket mode (default) picks up tasks instantly with zero idle cost.${C.reset}`
1016
+ );
1017
+ console.log(
1018
+ `${C.dim} Remove --poll unless your network blocks WebSocket connections.${C.reset}
1019
+ `
1020
+ );
760
1021
  let running = true;
761
1022
  const shutdown = () => {
762
1023
  console.log("\n\u{1F6D1} Shutting down\u2026");
@@ -788,6 +1049,14 @@ async function runWithPolling(client, config, scheduler) {
788
1049
  }
789
1050
  }
790
1051
  async function main() {
1052
+ if (process.argv.includes("cleanup")) {
1053
+ loadEnvFile();
1054
+ const workDir = process.cwd();
1055
+ const workDirIdx = process.argv.indexOf("--work-dir");
1056
+ const resolvedDir = workDirIdx >= 0 ? path.resolve(process.argv[workDirIdx + 1] ?? ".") : workDir;
1057
+ await interactiveCleanup(resolvedDir);
1058
+ return;
1059
+ }
791
1060
  const config = parseArgs();
792
1061
  const client = new GroupChatAgentClient(config.apiUrl, config.token);
793
1062
  let me;
@@ -805,7 +1074,11 @@ async function main() {
805
1074
  if (config.apiUrl !== API_URL) console.log(` API: ${config.apiUrl}`);
806
1075
  if (config.model) console.log(` Model: ${config.model}`);
807
1076
  if (config.dryRun) console.log(` Mode: DRY RUN`);
1077
+ if (!config.useWorktrees) console.log(` Worktrees: disabled`);
808
1078
  console.log();
1079
+ if (config.useWorktrees) {
1080
+ await startupSweep(config.workDir);
1081
+ }
809
1082
  const scheduler = new TaskScheduler();
810
1083
  if (config.poll || config.once) {
811
1084
  await runWithPolling(client, config, scheduler);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {