@groupchatai/claude-runner 0.4.7 → 0.4.9

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 +188 -33
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -79,7 +79,7 @@ var GroupChatAgentClient = class {
79
79
  return this.request("POST", `/runs/${runId}/comment`, { body });
80
80
  }
81
81
  };
82
- function buildClaudePrompt(detail) {
82
+ function buildClaudePrompt(detail, repoResolved) {
83
83
  const parts = [];
84
84
  parts.push(`# Task: ${detail.task.title}`);
85
85
  if (detail.task.description) {
@@ -87,6 +87,19 @@ function buildClaudePrompt(detail) {
87
87
  ## Description
88
88
  ${detail.task.description}`);
89
89
  }
90
+ if (detail.repoUrl && !repoResolved) {
91
+ parts.push(
92
+ [
93
+ "\n## Repository",
94
+ detail.repoUrl,
95
+ "Check if the current working directory is already this repo (compare `git remote get-url origin`).",
96
+ "If not, check if it exists locally in common locations like ~/Developer, ~/Projects, ~/src, or ~.",
97
+ "If you find it locally, `cd` into it before starting work.",
98
+ "If you cannot find it locally, ask the user if they would like you to clone it and where.",
99
+ "Do NOT clone without the user's confirmation."
100
+ ].join("\n")
101
+ );
102
+ }
90
103
  if (detail.prompt && detail.prompt !== detail.task.title) {
91
104
  parts.push(`
92
105
  ## Instructions
@@ -114,6 +127,12 @@ ${comment.body}`
114
127
  );
115
128
  }
116
129
  }
130
+ const existingPrUrl = detail.pullRequestUrl ?? detail.task.pullRequestUrl;
131
+ if (existingPrUrl) {
132
+ parts.push(`
133
+ ## Existing Pull Request
134
+ ${existingPrUrl}`);
135
+ }
117
136
  if (detail.task.dueDate) {
118
137
  let dueStr = new Date(detail.task.dueDate).toLocaleDateString();
119
138
  if (detail.task.dueTime != null) {
@@ -126,12 +145,18 @@ ${comment.body}`
126
145
  parts.push(`
127
146
  Due: ${dueStr}`);
128
147
  }
148
+ const prRules = existingPrUrl ? [
149
+ `- This task already has a PR: ${existingPrUrl}. Push your changes to the EXISTING branch. Do NOT create a new PR.`,
150
+ "- You may push to existing branches and contribute to existing PRs."
151
+ ] : [
152
+ "- When you have made code changes, you MUST create a new branch, commit your changes, push to the remote, and open a PR using `gh pr create`. Do NOT skip PR creation \u2014 always open a real PR.",
153
+ "- You may push to existing branches and contribute to existing PRs."
154
+ ];
129
155
  parts.push(
130
156
  [
131
157
  "\n---",
132
158
  "RULES:",
133
- "- When you have made code changes, you MUST create a new branch, commit your changes, push to the remote, and open a PR using `gh pr create`. Do NOT skip PR creation \u2014 always open a real PR.",
134
- "- You may push to existing branches and contribute to existing PRs.",
159
+ ...prRules,
135
160
  "- NEVER run `gh pr merge`. Do NOT merge any PR.",
136
161
  "- NEVER run `gh pr close`. Do NOT close any PR.",
137
162
  "- NEVER run `git push --force` or `git push -f`. No force pushes.",
@@ -315,7 +340,23 @@ function safeFormatRateLimitPayload(o) {
315
340
  }
316
341
  function parseClaudeNdjsonEvents(rawOutput) {
317
342
  const events = [];
318
- for (const line of stripAnsi(rawOutput).split("\n")) {
343
+ const cleaned = stripAnsi(rawOutput).trim();
344
+ try {
345
+ const parsed = JSON.parse(cleaned);
346
+ if (Array.isArray(parsed)) {
347
+ for (const item of parsed) {
348
+ if (item && typeof item === "object" && typeof item.type === "string") {
349
+ events.push(item);
350
+ }
351
+ }
352
+ return events;
353
+ }
354
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
355
+ return [parsed];
356
+ }
357
+ } catch {
358
+ }
359
+ for (const line of cleaned.split("\n")) {
319
360
  const t = line.trim();
320
361
  if (!t.startsWith("{")) continue;
321
362
  try {
@@ -381,11 +422,7 @@ function claudeRunShouldReportAsError(exitCode, events) {
381
422
  return false;
382
423
  }
383
424
  function compactTaskErrorForApi(detail) {
384
- const s = detail.trim();
385
- if (/^rate limit \(/i.test(s)) {
386
- return s.split(/\s*—\s*/)[0]?.trim() ?? s;
387
- }
388
- return s;
425
+ return detail.trim();
389
426
  }
390
427
  function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
391
428
  const format = config.verbose ? "stream-json" : "json";
@@ -576,6 +613,14 @@ function runShellCommand(cmd, args, cwd) {
576
613
  });
577
614
  }
578
615
  var sessionCache = /* @__PURE__ */ new Map();
616
+ var activeProcesses = /* @__PURE__ */ new Map();
617
+ var stoppedRunIds = /* @__PURE__ */ new Set();
618
+ function stopActiveProcess(runId) {
619
+ const child = activeProcesses.get(runId);
620
+ if (!child || child.killed) return;
621
+ stoppedRunIds.add(runId);
622
+ child.kill("SIGINT");
623
+ }
579
624
  var runCounter = 0;
580
625
  var claudeRunnerSignalTeardownDone = false;
581
626
  function tryBeginClaudeRunnerSignalTeardown() {
@@ -628,25 +673,16 @@ function createWorktree(repoDir, taskId, existingBranch) {
628
673
  async function resolveExistingPrBranch(detail, workDir) {
629
674
  try {
630
675
  if (detail.branchName) return detail.branchName;
631
- if (detail.pullRequestUrl) {
632
- try {
633
- const branch2 = await runShellCommand(
634
- "gh",
635
- ["pr", "view", detail.pullRequestUrl, "--json", "headRefName", "--jq", ".headRefName"],
636
- workDir
637
- );
638
- if (branch2?.trim()) return branch2.trim();
639
- } catch {
640
- }
641
- }
642
- const prUrls = [];
676
+ const candidatePrUrls = [];
677
+ if (detail.pullRequestUrl) candidatePrUrls.push(detail.pullRequestUrl);
678
+ if (detail.task.pullRequestUrl) candidatePrUrls.push(detail.task.pullRequestUrl);
643
679
  for (const item of detail.activity) {
644
680
  if (item.body) {
645
681
  const matches = item.body.match(GITHUB_PR_URL_RE);
646
- if (matches) prUrls.push(...matches);
682
+ if (matches) candidatePrUrls.push(...matches);
647
683
  }
648
684
  }
649
- const prUrl = prUrls[prUrls.length - 1];
685
+ const prUrl = candidatePrUrls[0];
650
686
  if (!prUrl) return void 0;
651
687
  const branch = await runShellCommand(
652
688
  "gh",
@@ -889,7 +925,7 @@ function truncateClaudeDebugString(text) {
889
925
  truncated: true
890
926
  };
891
927
  }
892
- async function processRun(client, run, config, worktreeDir, detail) {
928
+ async function processRun(client, run, config, worktreeDir, detail, runBaseDir, repoFound) {
893
929
  const runNum = ++runCounter;
894
930
  const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
895
931
  const log = (msg) => console.log(`${runTag} ${msg}`);
@@ -900,7 +936,7 @@ async function processRun(client, run, config, worktreeDir, detail) {
900
936
  log(`\u23ED Run is no longer PENDING (now ${detail.status}), skipping.`);
901
937
  return;
902
938
  }
903
- const prompt = buildClaudePrompt(detail);
939
+ const prompt = buildClaudePrompt(detail, !!repoFound);
904
940
  if (config.dryRun) {
905
941
  log("\u{1F3DC} DRY RUN \u2014 would execute Claude Code with prompt:");
906
942
  console.log("---");
@@ -922,7 +958,7 @@ async function processRun(client, run, config, worktreeDir, detail) {
922
958
  throw err;
923
959
  }
924
960
  log("\u25B6 Run started");
925
- const effectiveCwd = worktreeDir ?? config.workDir;
961
+ const effectiveCwd = worktreeDir ?? runBaseDir ?? config.workDir;
926
962
  let lastClaude = {
927
963
  stderr: "",
928
964
  stdout: "",
@@ -958,8 +994,19 @@ async function processRun(client, run, config, worktreeDir, detail) {
958
994
  effectiveCwd
959
995
  );
960
996
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
997
+ activeProcesses.set(run.id, child);
998
+ if (stoppedRunIds.has(run.id)) {
999
+ child.kill("SIGINT");
1000
+ }
961
1001
  let { stdout, rawOutput, stderr, exitCode, sessionId, streamPrUrl } = await output;
1002
+ activeProcesses.delete(run.id);
962
1003
  lastClaude = { stderr, stdout, rawOutput, exitCode, sessionId };
1004
+ if (stoppedRunIds.has(run.id)) {
1005
+ stoppedRunIds.delete(run.id);
1006
+ if (sessionId) sessionCache.set(run.taskId, sessionId);
1007
+ log(`\u{1F6D1} Run stopped from UI`);
1008
+ return;
1009
+ }
963
1010
  if (exitCode !== 0 && isFollowUp) {
964
1011
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
965
1012
  sessionCache.delete(run.taskId);
@@ -1098,6 +1145,66 @@ function loadEnvFile() {
1098
1145
  }
1099
1146
  }
1100
1147
  }
1148
+ var repoRegistry = /* @__PURE__ */ new Map();
1149
+ function normalizeRepoUrl(url) {
1150
+ return url.replace(/\.git$/, "").replace(/^git@github\.com:/, "https://github.com/").toLowerCase();
1151
+ }
1152
+ function gitRemoteUrl(dir) {
1153
+ try {
1154
+ return execFileSync("git", ["remote", "get-url", "origin"], {
1155
+ cwd: dir,
1156
+ encoding: "utf-8",
1157
+ stdio: "pipe"
1158
+ }).trim();
1159
+ } catch {
1160
+ return void 0;
1161
+ }
1162
+ }
1163
+ function dirMatchesRepo(dir, repoUrl) {
1164
+ const remote = gitRemoteUrl(dir);
1165
+ if (!remote) return false;
1166
+ return normalizeRepoUrl(remote) === normalizeRepoUrl(repoUrl);
1167
+ }
1168
+ function scanForRepo(repoUrl) {
1169
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
1170
+ if (!home) return void 0;
1171
+ const searchDirs = ["Developer", "Projects", "src", "dev", "code", "repos"].map(
1172
+ (d) => path.join(home, d)
1173
+ );
1174
+ for (const searchDir of searchDirs) {
1175
+ let entries;
1176
+ try {
1177
+ entries = readdirSync(searchDir);
1178
+ } catch {
1179
+ continue;
1180
+ }
1181
+ for (const entry of entries) {
1182
+ if (entry.startsWith(".")) continue;
1183
+ const candidate = path.join(searchDir, entry);
1184
+ try {
1185
+ if (!statSync(candidate).isDirectory()) continue;
1186
+ } catch {
1187
+ continue;
1188
+ }
1189
+ if (dirMatchesRepo(candidate, repoUrl)) return candidate;
1190
+ }
1191
+ }
1192
+ return void 0;
1193
+ }
1194
+ function resolveRepoCwd(repoUrl, currentWorkDir) {
1195
+ const cached = repoRegistry.get(normalizeRepoUrl(repoUrl));
1196
+ if (cached) return cached;
1197
+ if (dirMatchesRepo(currentWorkDir, repoUrl)) {
1198
+ repoRegistry.set(normalizeRepoUrl(repoUrl), currentWorkDir);
1199
+ return currentWorkDir;
1200
+ }
1201
+ const found = scanForRepo(repoUrl);
1202
+ if (found) {
1203
+ repoRegistry.set(normalizeRepoUrl(repoUrl), found);
1204
+ return found;
1205
+ }
1206
+ return void 0;
1207
+ }
1101
1208
  function getRunnerPackageInfo() {
1102
1209
  try {
1103
1210
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -1312,12 +1419,21 @@ async function processRunWithDrain(client, run, scheduler, config) {
1312
1419
  drainSchedulerSlot(scheduler, run);
1313
1420
  return;
1314
1421
  }
1422
+ let runBaseDir = config.workDir;
1423
+ let repoFound = false;
1424
+ if (detail.repoUrl) {
1425
+ const resolved = resolveRepoCwd(detail.repoUrl, config.workDir);
1426
+ if (resolved) {
1427
+ runBaseDir = resolved;
1428
+ repoFound = true;
1429
+ }
1430
+ }
1315
1431
  let worktreeDir;
1316
- if (config.useWorktrees) {
1432
+ if (config.useWorktrees && (!detail.repoUrl || repoFound)) {
1317
1433
  try {
1318
- const existingBranch = await resolveExistingPrBranch(detail, config.workDir);
1319
- worktreeDir = createWorktree(config.workDir, run.taskId, existingBranch);
1320
- const rel = path.relative(config.workDir, worktreeDir);
1434
+ const existingBranch = await resolveExistingPrBranch(detail, runBaseDir);
1435
+ worktreeDir = createWorktree(runBaseDir, run.taskId, existingBranch);
1436
+ const rel = path.relative(runBaseDir, worktreeDir);
1321
1437
  if (existingBranch) {
1322
1438
  console.log(` \u{1F333} Worktree from existing PR branch ${existingBranch} \u2192 ${rel}`);
1323
1439
  } else {
@@ -1331,7 +1447,7 @@ async function processRunWithDrain(client, run, scheduler, config) {
1331
1447
  let currentDetail = detail;
1332
1448
  while (current) {
1333
1449
  try {
1334
- await processRun(client, current, config, worktreeDir, currentDetail);
1450
+ await processRun(client, current, config, worktreeDir, currentDetail, runBaseDir, repoFound);
1335
1451
  } catch (err) {
1336
1452
  console.error(`Unhandled error processing run ${current.id}:`, err);
1337
1453
  }
@@ -1348,14 +1464,19 @@ async function processRunWithDrain(client, run, scheduler, config) {
1348
1464
  }
1349
1465
  }
1350
1466
  if (worktreeDir) {
1351
- sessionCache.delete(run.taskId);
1352
1467
  try {
1353
- removeWorktreeSimple(config.workDir, worktreeDir);
1468
+ removeWorktreeSimple(runBaseDir, worktreeDir);
1354
1469
  console.log(` \u{1F9F9} Worktree cleaned up`);
1355
1470
  } catch (err) {
1356
1471
  console.error(` \u26A0 Failed to clean up worktree:`, err);
1357
1472
  }
1358
1473
  }
1474
+ if (detail.repoUrl && !repoFound) {
1475
+ const found = resolveRepoCwd(detail.repoUrl, config.workDir);
1476
+ if (found) {
1477
+ console.log(` \u{1F4C2} Repo cached for future runs: ${found}`);
1478
+ }
1479
+ }
1359
1480
  }
1360
1481
  async function runWithWebSocket(client, config, scheduler) {
1361
1482
  let ConvexClient;
@@ -1380,6 +1501,30 @@ async function runWithWebSocket(client, config, scheduler) {
1380
1501
  handlePendingRuns(runs, scheduler, client, config);
1381
1502
  }
1382
1503
  );
1504
+ let stoppedRunsWarned = false;
1505
+ convex.onUpdate(
1506
+ anyApi.agentWebSocket.stoppedRuns,
1507
+ { token: config.token },
1508
+ (runIds) => {
1509
+ if (!runIds) return;
1510
+ for (const runId of runIds) {
1511
+ if (activeProcesses.has(runId)) {
1512
+ console.log(` \u{1F6D1} Run stopped from UI \u2014 sending stop signal to Claude Code\u2026`);
1513
+ stopActiveProcess(runId);
1514
+ }
1515
+ }
1516
+ },
1517
+ () => {
1518
+ if (!stoppedRunsWarned) {
1519
+ stoppedRunsWarned = true;
1520
+ if (config.verbose) {
1521
+ console.log(
1522
+ `${C.dim}\u2139 stoppedRuns query not available on server \u2014 stop-from-UI disabled${C.reset}`
1523
+ );
1524
+ }
1525
+ }
1526
+ }
1527
+ );
1383
1528
  await new Promise((resolve) => {
1384
1529
  function shutdown() {
1385
1530
  if (!tryBeginClaudeRunnerSignalTeardown()) return;
@@ -1429,6 +1574,16 @@ async function runWithPolling(client, config, scheduler) {
1429
1574
  return;
1430
1575
  }
1431
1576
  while (running) {
1577
+ for (const runId of activeProcesses.keys()) {
1578
+ try {
1579
+ const detail = await client.getRunDetail(runId);
1580
+ if (detail.status === "STOPPED") {
1581
+ console.log(` \u{1F6D1} Run stopped from UI \u2014 sending stop signal to Claude Code\u2026`);
1582
+ stopActiveProcess(runId);
1583
+ }
1584
+ } catch {
1585
+ }
1586
+ }
1432
1587
  try {
1433
1588
  const pending = await client.listPendingRuns();
1434
1589
  handlePendingRuns(pending, scheduler, client, config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {