@groupchatai/claude-runner 0.4.2 → 0.4.5

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 +484 -30
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,8 +4,18 @@
4
4
  import { spawn, execFileSync } from "child_process";
5
5
  import { readFileSync, readdirSync, statSync, writeFileSync, existsSync, rmSync } from "fs";
6
6
  import path from "path";
7
+ import { fileURLToPath } from "url";
7
8
  var API_URL = "https://groupchat.ai";
8
9
  var CONVEX_URL = "https://fantastic-jay-464.convex.cloud";
10
+ function sanitizeAgentJsonPayload(payload) {
11
+ const out = {};
12
+ for (const [key, value] of Object.entries(payload)) {
13
+ if (value === void 0 || value === null) continue;
14
+ if (typeof value === "number" && !Number.isFinite(value)) continue;
15
+ out[key] = value;
16
+ }
17
+ return out;
18
+ }
9
19
  var GroupChatAgentClient = class {
10
20
  baseUrl;
11
21
  token;
@@ -15,13 +25,14 @@ var GroupChatAgentClient = class {
15
25
  }
16
26
  async request(method, endpoint, body) {
17
27
  const url = `${this.baseUrl}${endpoint}`;
28
+ const jsonBody = body ? JSON.stringify(sanitizeAgentJsonPayload(body)) : void 0;
18
29
  const res = await fetch(url, {
19
30
  method,
20
31
  headers: {
21
32
  Authorization: `Bearer ${this.token}`,
22
33
  "Content-Type": "application/json"
23
34
  },
24
- body: body ? JSON.stringify(body) : void 0,
35
+ body: jsonBody,
25
36
  redirect: "follow"
26
37
  });
27
38
  if (res.status === 401 && res.redirected) {
@@ -31,7 +42,7 @@ var GroupChatAgentClient = class {
31
42
  Authorization: `Bearer ${this.token}`,
32
43
  "Content-Type": "application/json"
33
44
  },
34
- body: body ? JSON.stringify(body) : void 0
45
+ body: jsonBody
35
46
  });
36
47
  if (!retry.ok) {
37
48
  const text = await retry.text().catch(() => "");
@@ -104,8 +115,16 @@ ${comment.body}`
104
115
  }
105
116
  }
106
117
  if (detail.task.dueDate) {
118
+ let dueStr = new Date(detail.task.dueDate).toLocaleDateString();
119
+ if (detail.task.dueTime != null) {
120
+ const h = Math.floor(detail.task.dueTime / 60);
121
+ const m = detail.task.dueTime % 60;
122
+ const period = h >= 12 ? "PM" : "AM";
123
+ const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
124
+ dueStr += ` at ${h12}:${String(m).padStart(2, "0")} ${period}`;
125
+ }
107
126
  parts.push(`
108
- Due: ${new Date(detail.task.dueDate).toLocaleDateString()}`);
127
+ Due: ${dueStr}`);
109
128
  }
110
129
  parts.push(
111
130
  [
@@ -240,6 +259,190 @@ var ALLOWED_TOOLS = [
240
259
  "TodoWrite",
241
260
  "NotebookEdit"
242
261
  ];
262
+ function clampUserFacingDetail(text, maxChars) {
263
+ const t = text.trim();
264
+ if (t.length <= maxChars) return t;
265
+ return `${t.slice(0, maxChars - 1)}\u2026`;
266
+ }
267
+ function stripAnsi(text) {
268
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
269
+ }
270
+ function errorMessageFromUnknown(err) {
271
+ if (typeof err === "string" && err.trim()) return err.trim();
272
+ if (err && typeof err === "object") {
273
+ const o = err;
274
+ if (typeof o.message === "string" && o.message.trim()) return o.message.trim();
275
+ if (typeof o.error === "string" && o.error.trim()) return o.error.trim();
276
+ }
277
+ return null;
278
+ }
279
+ function formatAnthropicRateLimitPayload(o) {
280
+ const rateLimitTypeRaw = typeof o.rateLimitType === "string" ? o.rateLimitType.replace(/_/g, " ") : "unknown";
281
+ const rawReset = o.resetsAt;
282
+ let resetMs = null;
283
+ if (typeof rawReset === "number" && Number.isFinite(rawReset)) {
284
+ resetMs = rawReset > 1e10 ? rawReset : rawReset * 1e3;
285
+ }
286
+ const status = typeof o.status === "string" ? o.status : null;
287
+ const overageReason = typeof o.overageDisabledReason === "string" ? o.overageDisabledReason : null;
288
+ const parts = [`Rate limit (${rateLimitTypeRaw})`];
289
+ if (status) parts.push(`status ${status}`);
290
+ if (resetMs !== null) {
291
+ const when = new Date(resetMs);
292
+ const local = when.toLocaleString(void 0, {
293
+ dateStyle: "medium",
294
+ timeStyle: "short"
295
+ });
296
+ parts.push(`resets at ${local} (${when.toISOString()} UTC)`);
297
+ }
298
+ if (overageReason) parts.push(`overage: ${overageReason}`);
299
+ return parts.join(" \u2014 ");
300
+ }
301
+ function safeFormatRateLimitPayload(o) {
302
+ try {
303
+ return formatAnthropicRateLimitPayload(o);
304
+ } catch {
305
+ const rt = typeof o.rateLimitType === "string" ? o.rateLimitType : "unknown";
306
+ const rawReset = o.resetsAt;
307
+ if (typeof rawReset === "number" && Number.isFinite(rawReset)) {
308
+ const ms = rawReset > 1e10 ? rawReset : rawReset * 1e3;
309
+ return `Rate limit (${rt}) \u2014 resets at ${new Date(ms).toISOString()} UTC`;
310
+ }
311
+ return `Rate limit (${rt})`;
312
+ }
313
+ }
314
+ function parseClaudeNdjsonEvents(rawOutput) {
315
+ const events = [];
316
+ for (const line of stripAnsi(rawOutput).split("\n")) {
317
+ const t = line.trim();
318
+ if (!t.startsWith("{")) continue;
319
+ try {
320
+ const ev = JSON.parse(t);
321
+ if (ev && typeof ev === "object" && typeof ev.type === "string") {
322
+ events.push(ev);
323
+ }
324
+ } catch {
325
+ }
326
+ }
327
+ return events;
328
+ }
329
+ function extractAssistantUserVisibleText(event) {
330
+ const msg = event.message;
331
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return null;
332
+ const content = msg.content;
333
+ if (!Array.isArray(content)) return null;
334
+ const parts = [];
335
+ for (const block of content) {
336
+ if (!block || typeof block !== "object") continue;
337
+ const b = block;
338
+ if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
339
+ parts.push(b.text.trim());
340
+ }
341
+ }
342
+ if (parts.length === 0) return null;
343
+ return parts.join("\n");
344
+ }
345
+ var USAGE_LIMIT_RE = /you['\u2019]?ve hit your limit/i;
346
+ function extractRateLimitRetryInfo(events) {
347
+ for (const ev of events) {
348
+ if (ev.type !== "rate_limit_event") continue;
349
+ const info = ev.rate_limit_info;
350
+ if (!info || typeof info !== "object" || Array.isArray(info)) continue;
351
+ const raw = info.resetsAt;
352
+ if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
353
+ const ms = raw > 1e10 ? raw : raw * 1e3;
354
+ return { errorType: "rate_limit", retryAfterMs: ms };
355
+ }
356
+ return null;
357
+ }
358
+ function isClaudeUsageLimitText(text) {
359
+ return USAGE_LIMIT_RE.test(text);
360
+ }
361
+ function extractPlainTextCliErrorFromOutput(rawOutput) {
362
+ const limitMatch = rawOutput.match(new RegExp(USAGE_LIMIT_RE.source + "[^\\n\\r]*", "gi"));
363
+ if (limitMatch?.[0]) return limitMatch[0].trim();
364
+ for (const line of rawOutput.split("\n")) {
365
+ const t = line.trim();
366
+ if (!t || t.startsWith("{")) continue;
367
+ if (/^API error:/i.test(t) || /^Error:/i.test(t) || /^Anthropic/i.test(t)) {
368
+ return t.length <= 2e3 ? t : `${t.slice(0, 1999)}\u2026`;
369
+ }
370
+ }
371
+ return null;
372
+ }
373
+ function deriveClaudeFailureSummary(exitCode, rawOutput, stderr, events) {
374
+ const stderrText = stripAnsi(stderr).trim();
375
+ if (stderrText.length > 0) {
376
+ return clampUserFacingDetail(stderrText, 2e3);
377
+ }
378
+ for (let i = events.length - 1; i >= 0; i--) {
379
+ const ev = events[i];
380
+ if (ev.type !== "result") continue;
381
+ if (typeof ev.error === "string" && ev.error.trim()) {
382
+ return clampUserFacingDetail(ev.error.trim(), 2e3);
383
+ }
384
+ const nested = errorMessageFromUnknown(ev.error);
385
+ if (nested) return clampUserFacingDetail(nested, 2e3);
386
+ if (ev.is_error === true && typeof ev.result === "string" && ev.result.trim()) {
387
+ return clampUserFacingDetail(ev.result.trim(), 2e3);
388
+ }
389
+ }
390
+ for (let i = events.length - 1; i >= 0; i--) {
391
+ const ev = events[i];
392
+ if (ev.type !== "error") continue;
393
+ if (typeof ev.message === "string" && ev.message.trim()) {
394
+ return clampUserFacingDetail(ev.message.trim(), 2e3);
395
+ }
396
+ if (typeof ev.error === "string" && ev.error.trim()) {
397
+ return clampUserFacingDetail(ev.error.trim(), 2e3);
398
+ }
399
+ const nested = errorMessageFromUnknown(ev.error);
400
+ if (nested) return clampUserFacingDetail(nested, 2e3);
401
+ }
402
+ for (let i = events.length - 1; i >= 0; i--) {
403
+ const ev = events[i];
404
+ if (ev.type !== "assistant") continue;
405
+ if (ev.error === void 0 || ev.error === null || ev.error === "") continue;
406
+ const assistantText = extractAssistantUserVisibleText(ev);
407
+ if (assistantText) return clampUserFacingDetail(assistantText, 2e3);
408
+ }
409
+ for (const ev of events) {
410
+ if (ev.type === "rate_limit_event") {
411
+ const info = ev.rate_limit_info;
412
+ if (info && typeof info === "object" && !Array.isArray(info)) {
413
+ return clampUserFacingDetail(
414
+ safeFormatRateLimitPayload(info),
415
+ 2e3
416
+ );
417
+ }
418
+ }
419
+ }
420
+ const combined = stripAnsi(`${stderr}
421
+ ${rawOutput}`);
422
+ const plain = extractPlainTextCliErrorFromOutput(combined);
423
+ if (plain) return clampUserFacingDetail(plain, 2e3);
424
+ return `Claude Code ended with exit code ${exitCode}. No detailed error was returned; if this persists, run the runner with verbose logging or try again later.`;
425
+ }
426
+ function claudeRunShouldReportAsError(options) {
427
+ if (options.exitCode !== 0) return true;
428
+ for (const ev of options.events) {
429
+ if (ev.type === "rate_limit_event") return true;
430
+ if (ev.type === "assistant" && ev.error !== void 0 && ev.error !== null && ev.error !== "") {
431
+ return true;
432
+ }
433
+ if (ev.type === "result" && ev.is_error === true) return true;
434
+ }
435
+ if (isClaudeUsageLimitText(options.resultSummaryText)) return true;
436
+ if (isClaudeUsageLimitText(options.combinedText)) return true;
437
+ return false;
438
+ }
439
+ function compactTaskErrorForApi(detail) {
440
+ const s = detail.trim();
441
+ if (/^rate limit \(/i.test(s)) {
442
+ return s.split(/\s*—\s*/)[0]?.trim() ?? s;
443
+ }
444
+ return s;
445
+ }
243
446
  function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
244
447
  const format = config.verbose ? "stream-json" : "json";
245
448
  const args = [];
@@ -350,6 +553,7 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverrid
350
553
  resolve({
351
554
  stdout,
352
555
  rawOutput,
556
+ stderr,
353
557
  exitCode: code ?? 0,
354
558
  sessionId: capturedSessionId,
355
559
  streamPrUrl: capturedPrUrl
@@ -445,6 +649,12 @@ function runShellCommand(cmd, args, cwd) {
445
649
  }
446
650
  var sessionCache = /* @__PURE__ */ new Map();
447
651
  var runCounter = 0;
652
+ var claudeRunnerSignalTeardownDone = false;
653
+ function tryBeginClaudeRunnerSignalTeardown() {
654
+ if (claudeRunnerSignalTeardownDone) return false;
655
+ claudeRunnerSignalTeardownDone = true;
656
+ return true;
657
+ }
448
658
  var WORKTREE_DIR = ".agent-worktrees";
449
659
  var WORKTREE_PREFIX = "task-";
450
660
  function worktreeNameForTask(taskId) {
@@ -461,15 +671,24 @@ function getDefaultBranch(repoDir) {
461
671
  return "main";
462
672
  }
463
673
  }
464
- function createWorktree(repoDir, taskId) {
674
+ function createWorktree(repoDir, taskId, existingBranch) {
465
675
  const name = worktreeNameForTask(taskId);
466
- const branchName = `agent/${name}-${Date.now()}`;
467
676
  const worktreeBase = path.join(repoDir, WORKTREE_DIR);
468
677
  const worktreePath = path.join(worktreeBase, name);
469
678
  if (existsSync(worktreePath)) {
470
679
  return worktreePath;
471
680
  }
681
+ if (existingBranch) {
682
+ try {
683
+ execGit(["fetch", "origin", existingBranch, "--quiet"], repoDir);
684
+ } catch {
685
+ }
686
+ execGit(["worktree", "add", worktreePath, `origin/${existingBranch}`], repoDir);
687
+ writeFileSync(path.join(worktreePath, ".agent-branch"), existingBranch, "utf-8");
688
+ return worktreePath;
689
+ }
472
690
  const baseBranch = getDefaultBranch(repoDir);
691
+ const branchName = `agent/${name}-${Date.now()}`;
473
692
  try {
474
693
  execGit(["fetch", "origin", baseBranch, "--quiet"], repoDir);
475
694
  } catch {
@@ -478,6 +697,39 @@ function createWorktree(repoDir, taskId) {
478
697
  writeFileSync(path.join(worktreePath, ".agent-branch"), branchName, "utf-8");
479
698
  return worktreePath;
480
699
  }
700
+ async function resolveExistingPrBranch(detail, workDir) {
701
+ try {
702
+ if (detail.branchName) return detail.branchName;
703
+ if (detail.pullRequestUrl) {
704
+ try {
705
+ const branch2 = await runShellCommand(
706
+ "gh",
707
+ ["pr", "view", detail.pullRequestUrl, "--json", "headRefName", "--jq", ".headRefName"],
708
+ workDir
709
+ );
710
+ if (branch2?.trim()) return branch2.trim();
711
+ } catch {
712
+ }
713
+ }
714
+ const prUrls = [];
715
+ for (const item of detail.activity) {
716
+ if (item.body) {
717
+ const matches = item.body.match(GITHUB_PR_URL_RE);
718
+ if (matches) prUrls.push(...matches);
719
+ }
720
+ }
721
+ const prUrl = prUrls[prUrls.length - 1];
722
+ if (!prUrl) return void 0;
723
+ const branch = await runShellCommand(
724
+ "gh",
725
+ ["pr", "view", prUrl, "--json", "headRefName", "--jq", ".headRefName"],
726
+ workDir
727
+ );
728
+ if (branch?.trim()) return branch.trim();
729
+ } catch {
730
+ }
731
+ return void 0;
732
+ }
481
733
  async function listOurWorktrees(workDir) {
482
734
  const worktreeDir = path.join(workDir, WORKTREE_DIR);
483
735
  let entries;
@@ -640,12 +892,52 @@ Removing ${safe.length} safe worktree(s)\u2026`);
640
892
  }
641
893
  console.log();
642
894
  }
643
- async function processRun(client, run, config, worktreeDir) {
895
+ function logClaudeRunFailureDiagnostics(log, stderr, rawOutput) {
896
+ const se = stripAnsi(stderr).trimEnd();
897
+ const so = stripAnsi(rawOutput);
898
+ log(`${C.dim}\u2500\u2500 Claude failure diagnostics (--verbose) \u2500\u2500${C.reset}`);
899
+ if (se.length > 0) {
900
+ log(`${C.dim}stderr:${C.reset}`);
901
+ for (const ln of se.split("\n")) {
902
+ log(`${C.dim} ${ln}${C.reset}`);
903
+ }
904
+ } else {
905
+ log(`${C.dim}(stderr empty)${C.reset}`);
906
+ }
907
+ const lines = so.length > 0 ? so.split("\n") : [];
908
+ const tailLines = lines.length > 200 ? lines.slice(-200) : lines;
909
+ log(`${C.dim}stdout: ${lines.length} line(s), showing last ${tailLines.length}${C.reset}`);
910
+ for (const ln of tailLines) {
911
+ log(`${C.dim} ${ln}${C.reset}`);
912
+ }
913
+ log(`${C.dim}\u2500\u2500 end diagnostics \u2500\u2500${C.reset}`);
914
+ }
915
+ var CLAUDE_DEBUG_JSON_MAX_CHARS = 1e6;
916
+ function logClaudeRawFailureJson(payload) {
917
+ const stamped = {
918
+ _tag: "claude-runner-claude-raw-failure",
919
+ _capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
920
+ ...payload
921
+ };
922
+ console.log(JSON.stringify(stamped, null, 2));
923
+ }
924
+ function truncateClaudeDebugString(text) {
925
+ const fullLength = text.length;
926
+ if (fullLength <= CLAUDE_DEBUG_JSON_MAX_CHARS) {
927
+ return { text, fullLength, truncated: false };
928
+ }
929
+ return {
930
+ text: `${text.slice(0, CLAUDE_DEBUG_JSON_MAX_CHARS)}
931
+ \u2026 [truncated; copy from logs or raise CLAUDE_DEBUG_JSON_MAX_CHARS]`,
932
+ fullLength,
933
+ truncated: true
934
+ };
935
+ }
936
+ async function processRun(client, run, config, worktreeDir, detail) {
644
937
  const runNum = ++runCounter;
645
938
  const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
646
939
  const log = (msg) => console.log(`${runTag} ${msg}`);
647
940
  const logGreen = (msg) => console.log(`${runTag} ${C.green}${msg}${C.reset}`);
648
- const detail = await client.getRunDetail(run.id);
649
941
  const ownerName = run.owner?.name ?? detail.owner?.name ?? "unknown";
650
942
  log(`\u{1F4CB} "${run.taskTitle}" \u2014 delegated by ${ownerName}`);
651
943
  if (detail.status !== "PENDING") {
@@ -675,6 +967,11 @@ async function processRun(client, run, config, worktreeDir) {
675
967
  }
676
968
  log("\u25B6 Run started");
677
969
  const effectiveCwd = worktreeDir ?? config.workDir;
970
+ let lastClaudeStderr = "";
971
+ let lastClaudeStdout = "";
972
+ let lastClaudeRawOutput = "";
973
+ let lastClaudeExitCode = null;
974
+ let lastClaudeSessionId;
678
975
  try {
679
976
  if (!worktreeDir && runOptions.branch) {
680
977
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
@@ -703,7 +1000,12 @@ async function processRun(client, run, config, worktreeDir) {
703
1000
  effectiveCwd
704
1001
  );
705
1002
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
706
- let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
1003
+ let { stdout, rawOutput, stderr, exitCode, sessionId, streamPrUrl } = await output;
1004
+ lastClaudeStderr = stderr;
1005
+ lastClaudeStdout = stdout;
1006
+ lastClaudeRawOutput = rawOutput;
1007
+ lastClaudeExitCode = exitCode;
1008
+ lastClaudeSessionId = sessionId;
707
1009
  if (exitCode !== 0 && isFollowUp) {
708
1010
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
709
1011
  sessionCache.delete(run.taskId);
@@ -712,21 +1014,81 @@ async function processRun(client, run, config, worktreeDir) {
712
1014
  const retryResult = await retry.output;
713
1015
  stdout = retryResult.stdout;
714
1016
  rawOutput = retryResult.rawOutput;
1017
+ stderr = retryResult.stderr;
715
1018
  exitCode = retryResult.exitCode;
716
1019
  sessionId = retryResult.sessionId;
717
1020
  streamPrUrl = retryResult.streamPrUrl;
1021
+ lastClaudeStderr = stderr;
1022
+ lastClaudeStdout = stdout;
1023
+ lastClaudeRawOutput = rawOutput;
1024
+ lastClaudeExitCode = exitCode;
1025
+ lastClaudeSessionId = sessionId;
718
1026
  }
719
1027
  if (sessionId) {
720
1028
  sessionCache.set(run.taskId, sessionId);
721
1029
  }
722
1030
  const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(effectiveCwd) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
723
- if (exitCode !== 0) {
724
- const errorMsg = `Claude Code exited with code ${exitCode}:
725
- \`\`\`
726
- ${stdout.slice(0, 2e3)}
727
- \`\`\``;
728
- await client.errorRun(run.id, errorMsg, { pullRequestUrl });
729
- log(`${C.red}\u274C Run errored (exit code ${exitCode})${C.reset}`);
1031
+ const streamEvents = parseClaudeNdjsonEvents(rawOutput);
1032
+ const combinedForLimit = stripAnsi(`${stderr}
1033
+ ${rawOutput}`);
1034
+ const resultSummaryText = extractResultText(stdout);
1035
+ const usageCap = claudeRunShouldReportAsError({
1036
+ exitCode,
1037
+ events: streamEvents,
1038
+ combinedText: combinedForLimit,
1039
+ resultSummaryText
1040
+ });
1041
+ if (exitCode !== 0 || usageCap) {
1042
+ const detail2 = deriveClaudeFailureSummary(exitCode, rawOutput, stderr, streamEvents);
1043
+ const errorMsg = compactTaskErrorForApi(detail2);
1044
+ if (process.env.GCA_DEBUG_CLAUDE_RAW === "1") {
1045
+ const stderrDbg = truncateClaudeDebugString(stripAnsi(stderr));
1046
+ const stdoutDbg = truncateClaudeDebugString(stripAnsi(stdout));
1047
+ const rawDbg = truncateClaudeDebugString(stripAnsi(rawOutput));
1048
+ logClaudeRawFailureJson({
1049
+ runId: run.id,
1050
+ taskId: run.taskId,
1051
+ taskTitle: run.taskTitle,
1052
+ exitCode,
1053
+ usageCap,
1054
+ sessionId: sessionId ?? null,
1055
+ streamPrUrl: streamPrUrl ?? null,
1056
+ pullRequestUrl: pullRequestUrl ?? null,
1057
+ formattedDetail: detail2,
1058
+ taskErrorMessageSentToApi: errorMsg,
1059
+ parsedEventTypes: streamEvents.map((e) => e.type),
1060
+ stderr: stderrDbg.text,
1061
+ stderrMeta: { fullLength: stderrDbg.fullLength, truncated: stderrDbg.truncated },
1062
+ stdout: stdoutDbg.text,
1063
+ stdoutMeta: { fullLength: stdoutDbg.fullLength, truncated: stdoutDbg.truncated },
1064
+ rawOutput: rawDbg.text,
1065
+ rawOutputMeta: { fullLength: rawDbg.fullLength, truncated: rawDbg.truncated },
1066
+ resultTextFromExtract: resultSummaryText || null
1067
+ });
1068
+ }
1069
+ const retryInfo = extractRateLimitRetryInfo(streamEvents);
1070
+ await client.errorRun(run.id, errorMsg, {
1071
+ pullRequestUrl,
1072
+ errorType: retryInfo?.errorType,
1073
+ retryAfter: retryInfo?.retryAfterMs
1074
+ });
1075
+ if (retryInfo) {
1076
+ const retryAt = new Date(retryInfo.retryAfterMs).toLocaleString(void 0, {
1077
+ dateStyle: "medium",
1078
+ timeStyle: "short"
1079
+ });
1080
+ log(`${C.red}\u23F8 Rate limited \u2014 server will retry at ${retryAt}${C.reset}`);
1081
+ } else {
1082
+ log(
1083
+ `${C.red}\u274C Run errored${exitCode !== 0 ? ` (exit code ${exitCode})` : " (usage limit)"}${C.reset}`
1084
+ );
1085
+ }
1086
+ for (const line of detail2.split("\n")) {
1087
+ log(`${C.red} ${line}${C.reset}`);
1088
+ }
1089
+ if (config.verbose) {
1090
+ logClaudeRunFailureDiagnostics(log, stderr, rawOutput);
1091
+ }
730
1092
  return;
731
1093
  }
732
1094
  const resultText = extractResultText(stdout);
@@ -736,6 +1098,28 @@ ${stdout.slice(0, 2e3)}
736
1098
  logGreen(`\u2705 Run completed`);
737
1099
  } catch (err) {
738
1100
  const message = err instanceof Error ? err.message : String(err);
1101
+ const stack = err instanceof Error ? err.stack : void 0;
1102
+ const stderrDbg = truncateClaudeDebugString(stripAnsi(lastClaudeStderr));
1103
+ const stdoutDbg = truncateClaudeDebugString(stripAnsi(lastClaudeStdout));
1104
+ const rawDbg = truncateClaudeDebugString(stripAnsi(lastClaudeRawOutput));
1105
+ if (process.env.GCA_DEBUG_CLAUDE_RAW === "1") {
1106
+ logClaudeRawFailureJson({
1107
+ runId: run.id,
1108
+ taskId: run.taskId,
1109
+ taskTitle: run.taskTitle,
1110
+ thrownMessage: message,
1111
+ thrownStack: stack ?? null,
1112
+ exitCode: lastClaudeExitCode,
1113
+ sessionId: lastClaudeSessionId ?? null,
1114
+ note: "Thrown before or after Claude finished; fields may be empty.",
1115
+ stderr: stderrDbg.text,
1116
+ stderrMeta: { fullLength: stderrDbg.fullLength, truncated: stderrDbg.truncated },
1117
+ stdout: stdoutDbg.text,
1118
+ stdoutMeta: { fullLength: stdoutDbg.fullLength, truncated: stdoutDbg.truncated },
1119
+ rawOutput: rawDbg.text,
1120
+ rawOutputMeta: { fullLength: rawDbg.fullLength, truncated: rawDbg.truncated }
1121
+ });
1122
+ }
739
1123
  const errorBody = `Claude Code runner error:
740
1124
  \`\`\`
741
1125
  ${message.slice(0, 2e3)}
@@ -772,6 +1156,25 @@ function loadEnvFile() {
772
1156
  }
773
1157
  }
774
1158
  }
1159
+ function getRunnerPackageInfo() {
1160
+ try {
1161
+ const here = path.dirname(fileURLToPath(import.meta.url));
1162
+ const pkgPath = path.join(here, "..", "package.json");
1163
+ const raw = readFileSync(pkgPath, "utf-8");
1164
+ const pkg = JSON.parse(raw);
1165
+ return {
1166
+ name: pkg.name ?? "@groupchatai/claude-runner",
1167
+ version: pkg.version ?? "unknown"
1168
+ };
1169
+ } catch {
1170
+ return { name: "@groupchatai/claude-runner", version: "unknown" };
1171
+ }
1172
+ }
1173
+ function showVersion() {
1174
+ const { name, version } = getRunnerPackageInfo();
1175
+ console.log(`${name} ${version}`);
1176
+ process.exit(0);
1177
+ }
775
1178
  function showHelp() {
776
1179
  console.log(`
777
1180
  Usage: npx @groupchatai/claude-runner [command] [options]
@@ -792,6 +1195,7 @@ Options:
792
1195
  --token <token> Agent token (or set GCA_TOKEN env var)
793
1196
  --no-worktree Disable git worktree isolation for runs
794
1197
  -h, --help Show this help message
1198
+ -v, -version, --version Print version and exit
795
1199
 
796
1200
  Environment variables:
797
1201
  GCA_TOKEN Agent token (gca_...)
@@ -850,6 +1254,11 @@ function parseArgs() {
850
1254
  case "-h":
851
1255
  showHelp();
852
1256
  break;
1257
+ case "--version":
1258
+ case "-v":
1259
+ case "-version":
1260
+ showVersion();
1261
+ break;
853
1262
  case "--no-worktree":
854
1263
  config.useWorktrees = false;
855
1264
  break;
@@ -919,6 +1328,12 @@ var TaskScheduler = class {
919
1328
  return this.slots.size === 0;
920
1329
  }
921
1330
  };
1331
+ function drainSchedulerSlot(scheduler, run) {
1332
+ let current = run;
1333
+ while (current) {
1334
+ current = scheduler.complete(current);
1335
+ }
1336
+ }
922
1337
  function handlePendingRuns(runs, scheduler, client, config) {
923
1338
  if (runs.length > 0 && config.verbose) {
924
1339
  console.log(`\u{1F4EC} ${runs.length} pending run(s)`);
@@ -943,27 +1358,47 @@ function handlePendingRuns(runs, scheduler, client, config) {
943
1358
  }
944
1359
  }
945
1360
  async function processRunWithDrain(client, run, scheduler, config) {
1361
+ let detail;
1362
+ try {
1363
+ detail = await client.getRunDetail(run.id);
1364
+ } catch (err) {
1365
+ console.error(` \u274C Failed to fetch run detail for ${run.id}:`, err);
1366
+ drainSchedulerSlot(scheduler, run);
1367
+ return;
1368
+ }
946
1369
  let worktreeDir;
947
1370
  if (config.useWorktrees) {
948
1371
  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
- );
1372
+ const existingBranch = await resolveExistingPrBranch(detail, config.workDir);
1373
+ worktreeDir = createWorktree(config.workDir, run.taskId, existingBranch);
1374
+ const rel = path.relative(config.workDir, worktreeDir);
1375
+ if (existingBranch) {
1376
+ console.log(` \u{1F333} Worktree from existing PR branch ${existingBranch} \u2192 ${rel}`);
1377
+ } else {
1378
+ console.log(` \u{1F333} Worktree created from main \u2192 ${rel}`);
1379
+ }
953
1380
  } catch (err) {
954
1381
  console.error(` \u26A0 Failed to create worktree, running in-place:`, err);
955
1382
  }
956
1383
  }
957
1384
  let current = run;
1385
+ let currentDetail = detail;
958
1386
  while (current) {
959
1387
  try {
960
- await processRun(client, current, config, worktreeDir);
1388
+ await processRun(client, current, config, worktreeDir, currentDetail);
961
1389
  } catch (err) {
962
1390
  console.error(`Unhandled error processing run ${current.id}:`, err);
963
1391
  }
964
1392
  current = scheduler.complete(current);
965
1393
  if (current) {
966
1394
  console.log(` \u23ED Processing queued follow-up\u2026`);
1395
+ try {
1396
+ currentDetail = await client.getRunDetail(current.id);
1397
+ } catch (err) {
1398
+ console.error(` \u274C Failed to fetch detail for follow-up ${current.id}:`, err);
1399
+ drainSchedulerSlot(scheduler, current);
1400
+ break;
1401
+ }
967
1402
  }
968
1403
  }
969
1404
  if (worktreeDir) {
@@ -1000,13 +1435,22 @@ async function runWithWebSocket(client, config, scheduler) {
1000
1435
  }
1001
1436
  );
1002
1437
  await new Promise((resolve) => {
1003
- const shutdown = () => {
1438
+ function shutdown() {
1439
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1440
+ process.removeListener("SIGINT", shutdown);
1441
+ process.removeListener("SIGTERM", shutdown);
1004
1442
  console.log("\n\u{1F6D1} Shutting down\u2026");
1005
- void convex.close();
1006
- resolve();
1007
- };
1008
- process.on("SIGINT", shutdown);
1009
- process.on("SIGTERM", shutdown);
1443
+ void (async () => {
1444
+ try {
1445
+ await convex.close();
1446
+ } catch {
1447
+ } finally {
1448
+ resolve();
1449
+ }
1450
+ })();
1451
+ }
1452
+ process.once("SIGINT", shutdown);
1453
+ process.once("SIGTERM", shutdown);
1010
1454
  });
1011
1455
  }
1012
1456
  async function runWithPolling(client, config, scheduler) {
@@ -1019,12 +1463,15 @@ async function runWithPolling(client, config, scheduler) {
1019
1463
  `
1020
1464
  );
1021
1465
  let running = true;
1022
- const shutdown = () => {
1466
+ function shutdownPoll() {
1467
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1468
+ process.removeListener("SIGINT", shutdownPoll);
1469
+ process.removeListener("SIGTERM", shutdownPoll);
1023
1470
  console.log("\n\u{1F6D1} Shutting down\u2026");
1024
1471
  running = false;
1025
- };
1026
- process.on("SIGINT", shutdown);
1027
- process.on("SIGTERM", shutdown);
1472
+ }
1473
+ process.once("SIGINT", shutdownPoll);
1474
+ process.once("SIGTERM", shutdownPoll);
1028
1475
  if (config.once) {
1029
1476
  try {
1030
1477
  const pending = await client.listPendingRuns();
@@ -1048,7 +1495,14 @@ async function runWithPolling(client, config, scheduler) {
1048
1495
  await sleep(config.pollInterval);
1049
1496
  }
1050
1497
  }
1498
+ function wantsVersionOnly(argv) {
1499
+ return argv.some((a) => a === "--version" || a === "-v" || a === "-version");
1500
+ }
1051
1501
  async function main() {
1502
+ const argv = process.argv.slice(2);
1503
+ if (wantsVersionOnly(argv)) {
1504
+ showVersion();
1505
+ }
1052
1506
  if (process.argv.includes("cleanup")) {
1053
1507
  loadEnvFile();
1054
1508
  const workDir = process.cwd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {