@groupchatai/claude-runner 0.4.6 → 0.4.8

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 +112 -133
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -114,6 +114,12 @@ ${comment.body}`
114
114
  );
115
115
  }
116
116
  }
117
+ const existingPrUrl = detail.pullRequestUrl ?? detail.task.pullRequestUrl;
118
+ if (existingPrUrl) {
119
+ parts.push(`
120
+ ## Existing Pull Request
121
+ ${existingPrUrl}`);
122
+ }
117
123
  if (detail.task.dueDate) {
118
124
  let dueStr = new Date(detail.task.dueDate).toLocaleDateString();
119
125
  if (detail.task.dueTime != null) {
@@ -126,12 +132,18 @@ ${comment.body}`
126
132
  parts.push(`
127
133
  Due: ${dueStr}`);
128
134
  }
135
+ const prRules = existingPrUrl ? [
136
+ `- This task already has a PR: ${existingPrUrl}. Push your changes to the EXISTING branch. Do NOT create a new PR.`,
137
+ "- You may push to existing branches and contribute to existing PRs."
138
+ ] : [
139
+ "- 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.",
140
+ "- You may push to existing branches and contribute to existing PRs."
141
+ ];
129
142
  parts.push(
130
143
  [
131
144
  "\n---",
132
145
  "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.",
146
+ ...prRules,
135
147
  "- NEVER run `gh pr merge`. Do NOT merge any PR.",
136
148
  "- NEVER run `gh pr close`. Do NOT close any PR.",
137
149
  "- NEVER run `git push --force` or `git push -f`. No force pushes.",
@@ -154,8 +166,7 @@ function pidTag(pid) {
154
166
  return ` ${C.pid}[pid ${pid}]${C.reset}`;
155
167
  }
156
168
  function padForTag(pid) {
157
- const tagLen = ` [pid ${pid}] `.length;
158
- return " ".repeat(tagLen);
169
+ return " ".repeat(8 + String(pid).length);
159
170
  }
160
171
  function wrapLines(tag, pad, text, color) {
161
172
  const lines = text.split("\n").filter((l) => l.trim());
@@ -277,13 +288,16 @@ function clampUserFacingDetail(text, maxChars) {
277
288
  function stripAnsi(text) {
278
289
  return text.replace(/\x1b\[[0-9;]*m/g, "");
279
290
  }
291
+ function errMsg(err) {
292
+ return err instanceof Error ? err.message : String(err);
293
+ }
294
+ function normalizeEpochMs(raw) {
295
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
296
+ return raw > 1e10 ? raw : raw * 1e3;
297
+ }
280
298
  function formatAnthropicRateLimitPayload(o) {
281
299
  const rateLimitTypeRaw = typeof o.rateLimitType === "string" ? o.rateLimitType.replace(/_/g, " ") : "unknown";
282
- const rawReset = o.resetsAt;
283
- let resetMs = null;
284
- if (typeof rawReset === "number" && Number.isFinite(rawReset)) {
285
- resetMs = rawReset > 1e10 ? rawReset : rawReset * 1e3;
286
- }
300
+ const resetMs = normalizeEpochMs(o.resetsAt);
287
301
  const status = typeof o.status === "string" ? o.status : null;
288
302
  const overageReason = typeof o.overageDisabledReason === "string" ? o.overageDisabledReason : null;
289
303
  const parts = [`Rate limit (${rateLimitTypeRaw})`];
@@ -304,9 +318,8 @@ function safeFormatRateLimitPayload(o) {
304
318
  return formatAnthropicRateLimitPayload(o);
305
319
  } catch {
306
320
  const rt = typeof o.rateLimitType === "string" ? o.rateLimitType : "unknown";
307
- const rawReset = o.resetsAt;
308
- if (typeof rawReset === "number" && Number.isFinite(rawReset)) {
309
- const ms = rawReset > 1e10 ? rawReset : rawReset * 1e3;
321
+ const ms = normalizeEpochMs(o.resetsAt);
322
+ if (ms !== null) {
310
323
  return `Rate limit (${rt}) \u2014 resets at ${new Date(ms).toISOString()} UTC`;
311
324
  }
312
325
  return `Rate limit (${rt})`;
@@ -314,7 +327,23 @@ function safeFormatRateLimitPayload(o) {
314
327
  }
315
328
  function parseClaudeNdjsonEvents(rawOutput) {
316
329
  const events = [];
317
- for (const line of stripAnsi(rawOutput).split("\n")) {
330
+ const cleaned = stripAnsi(rawOutput).trim();
331
+ try {
332
+ const parsed = JSON.parse(cleaned);
333
+ if (Array.isArray(parsed)) {
334
+ for (const item of parsed) {
335
+ if (item && typeof item === "object" && typeof item.type === "string") {
336
+ events.push(item);
337
+ }
338
+ }
339
+ return events;
340
+ }
341
+ if (parsed && typeof parsed === "object" && typeof parsed.type === "string") {
342
+ return [parsed];
343
+ }
344
+ } catch {
345
+ }
346
+ for (const line of cleaned.split("\n")) {
318
347
  const t = line.trim();
319
348
  if (!t.startsWith("{")) continue;
320
349
  try {
@@ -330,8 +359,7 @@ function parseClaudeNdjsonEvents(rawOutput) {
330
359
  function isRateLimitEventBlocking(ev) {
331
360
  const info = ev.rate_limit_info;
332
361
  if (!info || typeof info !== "object" || Array.isArray(info)) return true;
333
- const status = info.status;
334
- return !(typeof status === "string" && status === "allowed");
362
+ return info.status !== "allowed";
335
363
  }
336
364
  function extractRateLimitRetryInfo(events) {
337
365
  for (const ev of events) {
@@ -339,9 +367,8 @@ function extractRateLimitRetryInfo(events) {
339
367
  if (!isRateLimitEventBlocking(ev)) continue;
340
368
  const info = ev.rate_limit_info;
341
369
  if (!info || typeof info !== "object" || Array.isArray(info)) continue;
342
- const raw = info.resetsAt;
343
- if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
344
- const ms = raw > 1e10 ? raw : raw * 1e3;
370
+ const ms = normalizeEpochMs(info.resetsAt);
371
+ if (ms === null) continue;
345
372
  return { errorType: "rate_limit", retryAfterMs: ms };
346
373
  }
347
374
  return null;
@@ -382,11 +409,7 @@ function claudeRunShouldReportAsError(exitCode, events) {
382
409
  return false;
383
410
  }
384
411
  function compactTaskErrorForApi(detail) {
385
- const s = detail.trim();
386
- if (/^rate limit \(/i.test(s)) {
387
- return s.split(/\s*—\s*/)[0]?.trim() ?? s;
388
- }
389
- return s;
412
+ return detail.trim();
390
413
  }
391
414
  function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
392
415
  const format = config.verbose ? "stream-json" : "json";
@@ -435,31 +458,32 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverrid
435
458
  const match = combined.match(GITHUB_PR_URL_RE);
436
459
  if (match) capturedPrUrl = match[match.length - 1];
437
460
  }
461
+ function processLine(trimmed) {
462
+ try {
463
+ const event = JSON.parse(trimmed);
464
+ if (event.type === "system" && event.subtype === "init" && event.session_id) {
465
+ capturedSessionId = event.session_id;
466
+ }
467
+ if (event.type === "result") lastResultJson = trimmed;
468
+ checkEventForPrUrl(event);
469
+ if (config.verbose) {
470
+ const formatted = formatStreamEvent(event, pid);
471
+ if (formatted) console.log(formatted);
472
+ }
473
+ } catch {
474
+ if (config.verbose) {
475
+ console.log(`${pidTag(pid)} ${C.dim}${trimmed}${C.reset}`);
476
+ }
477
+ }
478
+ }
438
479
  child.stdout?.on("data", (data) => {
439
480
  chunks.push(data);
440
- const text = data.toString("utf-8");
441
- lineBuf += text;
481
+ lineBuf += data.toString("utf-8");
442
482
  const lines = lineBuf.split("\n");
443
483
  lineBuf = lines.pop() ?? "";
444
484
  for (const line of lines) {
445
485
  const trimmed = line.trim();
446
- if (!trimmed) continue;
447
- try {
448
- const event = JSON.parse(trimmed);
449
- if (event.type === "system" && event.subtype === "init" && event.session_id) {
450
- capturedSessionId = event.session_id;
451
- }
452
- if (event.type === "result") lastResultJson = trimmed;
453
- checkEventForPrUrl(event);
454
- if (config.verbose) {
455
- const formatted = formatStreamEvent(event, pid);
456
- if (formatted) console.log(formatted);
457
- }
458
- } catch {
459
- if (config.verbose) {
460
- console.log(`${pidTag(pid)} ${C.dim}${trimmed}${C.reset}`);
461
- }
462
- }
486
+ if (trimmed) processLine(trimmed);
463
487
  }
464
488
  });
465
489
  child.stderr?.on("data", (data) => {
@@ -471,24 +495,8 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverrid
471
495
  });
472
496
  child.on("error", (err) => reject(new Error(`Failed to spawn claude: ${err.message}`)));
473
497
  child.on("close", (code) => {
474
- if (lineBuf.trim()) {
475
- try {
476
- const event = JSON.parse(lineBuf.trim());
477
- if (event.type === "system" && event.subtype === "init" && event.session_id) {
478
- capturedSessionId = event.session_id;
479
- }
480
- if (event.type === "result") lastResultJson = lineBuf.trim();
481
- checkEventForPrUrl(event);
482
- if (config.verbose) {
483
- const formatted = formatStreamEvent(event, pid);
484
- if (formatted) console.log(formatted);
485
- }
486
- } catch {
487
- if (config.verbose) {
488
- console.log(`${pidTag(pid)} ${C.dim}${lineBuf.trim()}${C.reset}`);
489
- }
490
- }
491
- }
498
+ const remaining = lineBuf.trim();
499
+ if (remaining) processLine(remaining);
492
500
  const rawOutput = Buffer.concat(chunks).toString("utf-8");
493
501
  const stdout = config.verbose ? lastResultJson || rawOutput : rawOutput;
494
502
  const stderr = Buffer.concat(errChunks).toString("utf-8");
@@ -558,10 +566,9 @@ function extractPullRequestUrlFromText(text) {
558
566
  return void 0;
559
567
  }
560
568
  function extractPullRequestUrlFromOutput(stdout) {
561
- const event = findResultEvent(stdout);
562
- if (event) {
563
- const text = typeof event.result === "string" ? event.result : typeof event.text === "string" ? event.text : "";
564
- const found = extractPullRequestUrlFromText(text);
569
+ const resultText = extractResultText(stdout);
570
+ if (resultText !== stdout) {
571
+ const found = extractPullRequestUrlFromText(resultText);
565
572
  if (found) return found;
566
573
  }
567
574
  return extractPullRequestUrlFromText(stdout);
@@ -645,25 +652,16 @@ function createWorktree(repoDir, taskId, existingBranch) {
645
652
  async function resolveExistingPrBranch(detail, workDir) {
646
653
  try {
647
654
  if (detail.branchName) return detail.branchName;
648
- if (detail.pullRequestUrl) {
649
- try {
650
- const branch2 = await runShellCommand(
651
- "gh",
652
- ["pr", "view", detail.pullRequestUrl, "--json", "headRefName", "--jq", ".headRefName"],
653
- workDir
654
- );
655
- if (branch2?.trim()) return branch2.trim();
656
- } catch {
657
- }
658
- }
659
- const prUrls = [];
655
+ const candidatePrUrls = [];
656
+ if (detail.pullRequestUrl) candidatePrUrls.push(detail.pullRequestUrl);
657
+ if (detail.task.pullRequestUrl) candidatePrUrls.push(detail.task.pullRequestUrl);
660
658
  for (const item of detail.activity) {
661
659
  if (item.body) {
662
660
  const matches = item.body.match(GITHUB_PR_URL_RE);
663
- if (matches) prUrls.push(...matches);
661
+ if (matches) candidatePrUrls.push(...matches);
664
662
  }
665
663
  }
666
- const prUrl = prUrls[prUrls.length - 1];
664
+ const prUrl = candidatePrUrls[0];
667
665
  if (!prUrl) return void 0;
668
666
  const branch = await runShellCommand(
669
667
  "gh",
@@ -753,7 +751,7 @@ async function removeWorktree(workDir, info) {
753
751
  }
754
752
  return true;
755
753
  } catch (err) {
756
- console.error(` Failed to remove ${info.name}: ${err instanceof Error ? err.message : err}`);
754
+ console.error(` Failed to remove ${info.name}: ${errMsg(err)}`);
757
755
  return false;
758
756
  }
759
757
  }
@@ -931,7 +929,7 @@ async function processRun(client, run, config, worktreeDir, detail) {
931
929
  try {
932
930
  await client.startRun(run.id, startMsg);
933
931
  } catch (err) {
934
- const msg = err instanceof Error ? err.message : String(err);
932
+ const msg = errMsg(err);
935
933
  if (msg.includes("not pending") || msg.includes("not PENDING") || msg.includes("400")) {
936
934
  log(`\u23ED Run was already claimed, skipping.`);
937
935
  return;
@@ -940,11 +938,13 @@ async function processRun(client, run, config, worktreeDir, detail) {
940
938
  }
941
939
  log("\u25B6 Run started");
942
940
  const effectiveCwd = worktreeDir ?? config.workDir;
943
- let lastClaudeStderr = "";
944
- let lastClaudeStdout = "";
945
- let lastClaudeRawOutput = "";
946
- let lastClaudeExitCode = null;
947
- let lastClaudeSessionId;
941
+ let lastClaude = {
942
+ stderr: "",
943
+ stdout: "",
944
+ rawOutput: "",
945
+ exitCode: null,
946
+ sessionId: void 0
947
+ };
948
948
  try {
949
949
  if (!worktreeDir && runOptions.branch) {
950
950
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
@@ -974,28 +974,14 @@ async function processRun(client, run, config, worktreeDir, detail) {
974
974
  );
975
975
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
976
976
  let { stdout, rawOutput, stderr, exitCode, sessionId, streamPrUrl } = await output;
977
- lastClaudeStderr = stderr;
978
- lastClaudeStdout = stdout;
979
- lastClaudeRawOutput = rawOutput;
980
- lastClaudeExitCode = exitCode;
981
- lastClaudeSessionId = sessionId;
977
+ lastClaude = { stderr, stdout, rawOutput, exitCode, sessionId };
982
978
  if (exitCode !== 0 && isFollowUp) {
983
979
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
984
980
  sessionCache.delete(run.taskId);
985
981
  const retry = spawnClaudeCode(prompt, config, runOptions, void 0, effectiveCwd);
986
982
  log(`\u{1F916} Claude Code spawned (pid ${retry.process.pid}) (fresh)`);
987
- const retryResult = await retry.output;
988
- stdout = retryResult.stdout;
989
- rawOutput = retryResult.rawOutput;
990
- stderr = retryResult.stderr;
991
- exitCode = retryResult.exitCode;
992
- sessionId = retryResult.sessionId;
993
- streamPrUrl = retryResult.streamPrUrl;
994
- lastClaudeStderr = stderr;
995
- lastClaudeStdout = stdout;
996
- lastClaudeRawOutput = rawOutput;
997
- lastClaudeExitCode = exitCode;
998
- lastClaudeSessionId = sessionId;
983
+ ({ stdout, rawOutput, stderr, exitCode, sessionId, streamPrUrl } = await retry.output);
984
+ lastClaude = { stderr, stdout, rawOutput, exitCode, sessionId };
999
985
  }
1000
986
  if (sessionId) {
1001
987
  sessionCache.set(run.taskId, sessionId);
@@ -1068,11 +1054,11 @@ async function processRun(client, run, config, worktreeDir, detail) {
1068
1054
  if (pullRequestUrl) logGreen(`\u{1F517} PR: ${pullRequestUrl}`);
1069
1055
  logGreen(`\u2705 Run completed`);
1070
1056
  } catch (err) {
1071
- const message = err instanceof Error ? err.message : String(err);
1057
+ const message = errMsg(err);
1072
1058
  const stack = err instanceof Error ? err.stack : void 0;
1073
- const stderrDbg = truncateClaudeDebugString(stripAnsi(lastClaudeStderr));
1074
- const stdoutDbg = truncateClaudeDebugString(stripAnsi(lastClaudeStdout));
1075
- const rawDbg = truncateClaudeDebugString(stripAnsi(lastClaudeRawOutput));
1059
+ const stderrDbg = truncateClaudeDebugString(stripAnsi(lastClaude.stderr));
1060
+ const stdoutDbg = truncateClaudeDebugString(stripAnsi(lastClaude.stdout));
1061
+ const rawDbg = truncateClaudeDebugString(stripAnsi(lastClaude.rawOutput));
1076
1062
  if (process.env.GCA_DEBUG_CLAUDE_RAW === "1") {
1077
1063
  logClaudeRawFailureJson({
1078
1064
  runId: run.id,
@@ -1080,8 +1066,8 @@ async function processRun(client, run, config, worktreeDir, detail) {
1080
1066
  taskTitle: run.taskTitle,
1081
1067
  thrownMessage: message,
1082
1068
  thrownStack: stack ?? null,
1083
- exitCode: lastClaudeExitCode,
1084
- sessionId: lastClaudeSessionId ?? null,
1069
+ exitCode: lastClaude.exitCode,
1070
+ sessionId: lastClaude.sessionId ?? null,
1085
1071
  note: "Thrown before or after Claude finished; fields may be empty.",
1086
1072
  stderr: stderrDbg.text,
1087
1073
  stderrMeta: { fullLength: stderrDbg.fullLength, truncated: stderrDbg.truncated },
@@ -1185,6 +1171,7 @@ function parseArgs() {
1185
1171
  workDir: process.cwd(),
1186
1172
  pollInterval: 3e4,
1187
1173
  maxConcurrent: 5,
1174
+ command: "run",
1188
1175
  poll: false,
1189
1176
  dryRun: false,
1190
1177
  once: false,
@@ -1234,20 +1221,23 @@ function parseArgs() {
1234
1221
  config.useWorktrees = false;
1235
1222
  break;
1236
1223
  case "cleanup":
1224
+ config.command = "cleanup";
1237
1225
  break;
1238
1226
  default:
1239
1227
  console.error(`Unknown argument: ${arg}`);
1240
1228
  process.exit(1);
1241
1229
  }
1242
1230
  }
1243
- if (!config.token) {
1244
- console.error("Error: No agent token found.");
1245
- console.error(" Add GCA_TOKEN=gca_... to .env.local, or pass --token gca_...");
1246
- process.exit(1);
1247
- }
1248
- if (!config.token.startsWith("gca_")) {
1249
- console.error(`Error: Token must start with gca_ (got "${config.token.slice(0, 8)}\u2026")`);
1250
- process.exit(1);
1231
+ if (config.command !== "cleanup") {
1232
+ if (!config.token) {
1233
+ console.error("Error: No agent token found.");
1234
+ console.error(" Add GCA_TOKEN=gca_... to .env.local, or pass --token gca_...");
1235
+ process.exit(1);
1236
+ }
1237
+ if (!config.token.startsWith("gca_")) {
1238
+ console.error(`Error: Token must start with gca_ (got "${config.token.slice(0, 8)}\u2026")`);
1239
+ process.exit(1);
1240
+ }
1251
1241
  }
1252
1242
  return config;
1253
1243
  }
@@ -1448,7 +1438,7 @@ async function runWithPolling(client, config, scheduler) {
1448
1438
  const pending = await client.listPendingRuns();
1449
1439
  handlePendingRuns(pending, scheduler, client, config);
1450
1440
  } catch (err) {
1451
- console.error(`Poll error: ${err instanceof Error ? err.message : err}`);
1441
+ console.error(`Poll error: ${errMsg(err)}`);
1452
1442
  }
1453
1443
  while (!scheduler.isEmpty()) await sleep(1e3);
1454
1444
  return;
@@ -1458,7 +1448,7 @@ async function runWithPolling(client, config, scheduler) {
1458
1448
  const pending = await client.listPendingRuns();
1459
1449
  handlePendingRuns(pending, scheduler, client, config);
1460
1450
  } catch (err) {
1461
- const msg = err instanceof Error ? err.message : String(err);
1451
+ const msg = errMsg(err);
1462
1452
  if (config.verbose || !msg.includes("fetch")) {
1463
1453
  console.error(`Poll error: ${msg}`);
1464
1454
  }
@@ -1466,29 +1456,18 @@ async function runWithPolling(client, config, scheduler) {
1466
1456
  await sleep(config.pollInterval);
1467
1457
  }
1468
1458
  }
1469
- function wantsVersionOnly(argv) {
1470
- return argv.some((a) => a === "--version" || a === "-v" || a === "-version");
1471
- }
1472
1459
  async function main() {
1473
- const argv = process.argv.slice(2);
1474
- if (wantsVersionOnly(argv)) {
1475
- showVersion();
1476
- }
1477
- if (process.argv.includes("cleanup")) {
1478
- loadEnvFile();
1479
- const workDir = process.cwd();
1480
- const workDirIdx = process.argv.indexOf("--work-dir");
1481
- const resolvedDir = workDirIdx >= 0 ? path.resolve(process.argv[workDirIdx + 1] ?? ".") : workDir;
1482
- await interactiveCleanup(resolvedDir);
1460
+ const config = parseArgs();
1461
+ if (config.command === "cleanup") {
1462
+ await interactiveCleanup(config.workDir);
1483
1463
  return;
1484
1464
  }
1485
- const config = parseArgs();
1486
1465
  const client = new GroupChatAgentClient(config.apiUrl, config.token);
1487
1466
  let me;
1488
1467
  try {
1489
1468
  me = await client.getMe();
1490
1469
  } catch (err) {
1491
- console.error("Failed to authenticate:", err instanceof Error ? err.message : err);
1470
+ console.error("Failed to authenticate:", errMsg(err));
1492
1471
  process.exit(1);
1493
1472
  }
1494
1473
  console.log(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {