@groupchatai/claude-runner 0.4.3 → 0.4.6

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 +391 -23
  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
  [
@@ -184,6 +203,16 @@ function formatStreamEvent(event, pid) {
184
203
  }
185
204
  case "tool_result":
186
205
  return null;
206
+ case "rate_limit_event": {
207
+ const info = event.rate_limit_info;
208
+ if (info && typeof info === "object" && !Array.isArray(info)) {
209
+ const payload = safeFormatRateLimitPayload(info);
210
+ const blocking = isRateLimitEventBlocking(event);
211
+ const icon = blocking ? "\u{1F6AB}" : "\u2139\uFE0F ";
212
+ return `${tag} ${C.dim}${icon} ${payload}${blocking ? "" : " (non-blocking)"}${C.reset}`;
213
+ }
214
+ return `${tag} ${C.dim}rate_limit_event (no info payload)${C.reset}`;
215
+ }
187
216
  case "result": {
188
217
  const lines = [];
189
218
  if (event.cost_usd !== void 0 || event.total_cost_usd !== void 0) {
@@ -240,6 +269,125 @@ var ALLOWED_TOOLS = [
240
269
  "TodoWrite",
241
270
  "NotebookEdit"
242
271
  ];
272
+ function clampUserFacingDetail(text, maxChars) {
273
+ const t = text.trim();
274
+ if (t.length <= maxChars) return t;
275
+ return `${t.slice(0, maxChars - 1)}\u2026`;
276
+ }
277
+ function stripAnsi(text) {
278
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
279
+ }
280
+ function formatAnthropicRateLimitPayload(o) {
281
+ 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
+ }
287
+ const status = typeof o.status === "string" ? o.status : null;
288
+ const overageReason = typeof o.overageDisabledReason === "string" ? o.overageDisabledReason : null;
289
+ const parts = [`Rate limit (${rateLimitTypeRaw})`];
290
+ if (status) parts.push(`status ${status}`);
291
+ if (resetMs !== null) {
292
+ const when = new Date(resetMs);
293
+ const local = when.toLocaleString(void 0, {
294
+ dateStyle: "medium",
295
+ timeStyle: "short"
296
+ });
297
+ parts.push(`resets at ${local} (${when.toISOString()} UTC)`);
298
+ }
299
+ if (overageReason) parts.push(`overage: ${overageReason}`);
300
+ return parts.join(" \u2014 ");
301
+ }
302
+ function safeFormatRateLimitPayload(o) {
303
+ try {
304
+ return formatAnthropicRateLimitPayload(o);
305
+ } catch {
306
+ 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;
310
+ return `Rate limit (${rt}) \u2014 resets at ${new Date(ms).toISOString()} UTC`;
311
+ }
312
+ return `Rate limit (${rt})`;
313
+ }
314
+ }
315
+ function parseClaudeNdjsonEvents(rawOutput) {
316
+ const events = [];
317
+ for (const line of stripAnsi(rawOutput).split("\n")) {
318
+ const t = line.trim();
319
+ if (!t.startsWith("{")) continue;
320
+ try {
321
+ const ev = JSON.parse(t);
322
+ if (ev && typeof ev === "object" && typeof ev.type === "string") {
323
+ events.push(ev);
324
+ }
325
+ } catch {
326
+ }
327
+ }
328
+ return events;
329
+ }
330
+ function isRateLimitEventBlocking(ev) {
331
+ const info = ev.rate_limit_info;
332
+ if (!info || typeof info !== "object" || Array.isArray(info)) return true;
333
+ const status = info.status;
334
+ return !(typeof status === "string" && status === "allowed");
335
+ }
336
+ function extractRateLimitRetryInfo(events) {
337
+ for (const ev of events) {
338
+ if (ev.type !== "rate_limit_event") continue;
339
+ if (!isRateLimitEventBlocking(ev)) continue;
340
+ const info = ev.rate_limit_info;
341
+ 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;
345
+ return { errorType: "rate_limit", retryAfterMs: ms };
346
+ }
347
+ return null;
348
+ }
349
+ function deriveClaudeFailureSummary(exitCode, stderr, events) {
350
+ for (let i = events.length - 1; i >= 0; i--) {
351
+ const ev = events[i];
352
+ if (ev.type !== "result") continue;
353
+ if (ev.is_error === true && typeof ev.result === "string" && ev.result.trim()) {
354
+ return clampUserFacingDetail(ev.result.trim(), 2e3);
355
+ }
356
+ if (typeof ev.error === "string" && ev.error.trim()) {
357
+ return clampUserFacingDetail(ev.error.trim(), 2e3);
358
+ }
359
+ }
360
+ const stderrText = stripAnsi(stderr).trim();
361
+ if (stderrText.length > 0) {
362
+ return clampUserFacingDetail(stderrText, 2e3);
363
+ }
364
+ for (const ev of events) {
365
+ if (ev.type === "rate_limit_event" && isRateLimitEventBlocking(ev)) {
366
+ const info = ev.rate_limit_info;
367
+ if (info && typeof info === "object" && !Array.isArray(info)) {
368
+ return clampUserFacingDetail(
369
+ safeFormatRateLimitPayload(info),
370
+ 2e3
371
+ );
372
+ }
373
+ }
374
+ }
375
+ return `Claude Code ended with exit code ${exitCode}`;
376
+ }
377
+ function claudeRunShouldReportAsError(exitCode, events) {
378
+ if (exitCode !== 0) return true;
379
+ for (let i = events.length - 1; i >= 0; i--) {
380
+ if (events[i].type === "result" && events[i].is_error === true) return true;
381
+ }
382
+ return false;
383
+ }
384
+ 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;
390
+ }
243
391
  function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverride) {
244
392
  const format = config.verbose ? "stream-json" : "json";
245
393
  const args = [];
@@ -350,6 +498,7 @@ function spawnClaudeCode(prompt, config, runOptions, resumeSessionId, cwdOverrid
350
498
  resolve({
351
499
  stdout,
352
500
  rawOutput,
501
+ stderr,
353
502
  exitCode: code ?? 0,
354
503
  sessionId: capturedSessionId,
355
504
  streamPrUrl: capturedPrUrl
@@ -445,6 +594,12 @@ function runShellCommand(cmd, args, cwd) {
445
594
  }
446
595
  var sessionCache = /* @__PURE__ */ new Map();
447
596
  var runCounter = 0;
597
+ var claudeRunnerSignalTeardownDone = false;
598
+ function tryBeginClaudeRunnerSignalTeardown() {
599
+ if (claudeRunnerSignalTeardownDone) return false;
600
+ claudeRunnerSignalTeardownDone = true;
601
+ return true;
602
+ }
448
603
  var WORKTREE_DIR = ".agent-worktrees";
449
604
  var WORKTREE_PREFIX = "task-";
450
605
  function worktreeNameForTask(taskId) {
@@ -682,6 +837,75 @@ Removing ${safe.length} safe worktree(s)\u2026`);
682
837
  }
683
838
  console.log();
684
839
  }
840
+ function logClaudeRunFailureDiagnostics(log, opts) {
841
+ const se = stripAnsi(opts.stderr).trimEnd();
842
+ const so = stripAnsi(opts.rawOutput);
843
+ log(`${C.dim}\u2500\u2500 Claude failure diagnostics (--verbose) \u2500\u2500${C.reset}`);
844
+ log(`${C.dim} exit code: ${opts.exitCode}${C.reset}`);
845
+ log(`${C.dim} session: ${opts.sessionId ?? "(none)"}${C.reset}`);
846
+ log(`${C.dim} derived error: ${opts.derivedError}${C.reset}`);
847
+ log(`${C.dim} api error: ${opts.apiError}${C.reset}`);
848
+ const rateLimitEvents = opts.streamEvents.filter((e) => e.type === "rate_limit_event");
849
+ if (rateLimitEvents.length > 0) {
850
+ log(`${C.dim} rate_limit_event(s): ${rateLimitEvents.length}${C.reset}`);
851
+ for (const rl of rateLimitEvents) {
852
+ const info = rl.rate_limit_info;
853
+ const blocking = isRateLimitEventBlocking(rl);
854
+ if (info && typeof info === "object" && !Array.isArray(info)) {
855
+ log(`${C.dim} blocking=${blocking} payload=${JSON.stringify(info)}${C.reset}`);
856
+ } else {
857
+ log(`${C.dim} blocking=${blocking} (no rate_limit_info)${C.reset}`);
858
+ }
859
+ }
860
+ }
861
+ const typeCounts = /* @__PURE__ */ new Map();
862
+ for (const ev of opts.streamEvents) {
863
+ const t = typeof ev.type === "string" ? ev.type : "?";
864
+ typeCounts.set(t, (typeCounts.get(t) ?? 0) + 1);
865
+ }
866
+ if (typeCounts.size > 0) {
867
+ const timeline = Array.from(typeCounts.entries()).map(([t, c]) => c > 1 ? `${t}\xD7${c}` : t).join(", ");
868
+ log(`${C.dim} events: ${opts.streamEvents.length} total (${timeline})${C.reset}`);
869
+ } else {
870
+ log(`${C.dim} events: 0 parsed${C.reset}`);
871
+ }
872
+ if (se.length > 0) {
873
+ log(`${C.dim} stderr:${C.reset}`);
874
+ for (const ln of se.split("\n")) {
875
+ log(`${C.dim} ${ln}${C.reset}`);
876
+ }
877
+ } else {
878
+ log(`${C.dim} stderr: (empty)${C.reset}`);
879
+ }
880
+ const lines = so.length > 0 ? so.split("\n") : [];
881
+ const tailLines = lines.length > 200 ? lines.slice(-200) : lines;
882
+ log(`${C.dim} stdout: ${lines.length} line(s), showing last ${tailLines.length}${C.reset}`);
883
+ for (const ln of tailLines) {
884
+ log(`${C.dim} ${ln}${C.reset}`);
885
+ }
886
+ log(`${C.dim}\u2500\u2500 end diagnostics \u2500\u2500${C.reset}`);
887
+ }
888
+ var CLAUDE_DEBUG_JSON_MAX_CHARS = 1e6;
889
+ function logClaudeRawFailureJson(payload) {
890
+ const stamped = {
891
+ _tag: "claude-runner-claude-raw-failure",
892
+ _capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
893
+ ...payload
894
+ };
895
+ console.log(JSON.stringify(stamped, null, 2));
896
+ }
897
+ function truncateClaudeDebugString(text) {
898
+ const fullLength = text.length;
899
+ if (fullLength <= CLAUDE_DEBUG_JSON_MAX_CHARS) {
900
+ return { text, fullLength, truncated: false };
901
+ }
902
+ return {
903
+ text: `${text.slice(0, CLAUDE_DEBUG_JSON_MAX_CHARS)}
904
+ \u2026 [truncated; copy from logs or raise CLAUDE_DEBUG_JSON_MAX_CHARS]`,
905
+ fullLength,
906
+ truncated: true
907
+ };
908
+ }
685
909
  async function processRun(client, run, config, worktreeDir, detail) {
686
910
  const runNum = ++runCounter;
687
911
  const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
@@ -716,6 +940,11 @@ async function processRun(client, run, config, worktreeDir, detail) {
716
940
  }
717
941
  log("\u25B6 Run started");
718
942
  const effectiveCwd = worktreeDir ?? config.workDir;
943
+ let lastClaudeStderr = "";
944
+ let lastClaudeStdout = "";
945
+ let lastClaudeRawOutput = "";
946
+ let lastClaudeExitCode = null;
947
+ let lastClaudeSessionId;
719
948
  try {
720
949
  if (!worktreeDir && runOptions.branch) {
721
950
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
@@ -744,7 +973,12 @@ async function processRun(client, run, config, worktreeDir, detail) {
744
973
  effectiveCwd
745
974
  );
746
975
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
747
- let { stdout, rawOutput, exitCode, sessionId, streamPrUrl } = await output;
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;
748
982
  if (exitCode !== 0 && isFollowUp) {
749
983
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
750
984
  sessionCache.delete(run.taskId);
@@ -753,21 +987,79 @@ async function processRun(client, run, config, worktreeDir, detail) {
753
987
  const retryResult = await retry.output;
754
988
  stdout = retryResult.stdout;
755
989
  rawOutput = retryResult.rawOutput;
990
+ stderr = retryResult.stderr;
756
991
  exitCode = retryResult.exitCode;
757
992
  sessionId = retryResult.sessionId;
758
993
  streamPrUrl = retryResult.streamPrUrl;
994
+ lastClaudeStderr = stderr;
995
+ lastClaudeStdout = stdout;
996
+ lastClaudeRawOutput = rawOutput;
997
+ lastClaudeExitCode = exitCode;
998
+ lastClaudeSessionId = sessionId;
759
999
  }
760
1000
  if (sessionId) {
761
1001
  sessionCache.set(run.taskId, sessionId);
762
1002
  }
763
1003
  const pullRequestUrl = streamPrUrl ?? await detectPullRequestUrl(effectiveCwd) ?? extractPullRequestUrlFromOutput(stdout) ?? extractPullRequestUrlFromOutput(rawOutput);
764
- if (exitCode !== 0) {
765
- const errorMsg = `Claude Code exited with code ${exitCode}:
766
- \`\`\`
767
- ${stdout.slice(0, 2e3)}
768
- \`\`\``;
769
- await client.errorRun(run.id, errorMsg, { pullRequestUrl });
770
- log(`${C.red}\u274C Run errored (exit code ${exitCode})${C.reset}`);
1004
+ const streamEvents = parseClaudeNdjsonEvents(rawOutput);
1005
+ const isError = claudeRunShouldReportAsError(exitCode, streamEvents);
1006
+ if (isError) {
1007
+ const detail2 = deriveClaudeFailureSummary(exitCode, stderr, streamEvents);
1008
+ const errorMsg = compactTaskErrorForApi(detail2);
1009
+ if (process.env.GCA_DEBUG_CLAUDE_RAW === "1") {
1010
+ const stderrDbg = truncateClaudeDebugString(stripAnsi(stderr));
1011
+ const stdoutDbg = truncateClaudeDebugString(stripAnsi(stdout));
1012
+ const rawDbg = truncateClaudeDebugString(stripAnsi(rawOutput));
1013
+ logClaudeRawFailureJson({
1014
+ runId: run.id,
1015
+ taskId: run.taskId,
1016
+ taskTitle: run.taskTitle,
1017
+ exitCode,
1018
+ sessionId: sessionId ?? null,
1019
+ streamPrUrl: streamPrUrl ?? null,
1020
+ pullRequestUrl: pullRequestUrl ?? null,
1021
+ formattedDetail: detail2,
1022
+ taskErrorMessageSentToApi: errorMsg,
1023
+ parsedEventTypes: streamEvents.map((e) => e.type),
1024
+ stderr: stderrDbg.text,
1025
+ stderrMeta: { fullLength: stderrDbg.fullLength, truncated: stderrDbg.truncated },
1026
+ stdout: stdoutDbg.text,
1027
+ stdoutMeta: { fullLength: stdoutDbg.fullLength, truncated: stdoutDbg.truncated },
1028
+ rawOutput: rawDbg.text,
1029
+ rawOutputMeta: { fullLength: rawDbg.fullLength, truncated: rawDbg.truncated }
1030
+ });
1031
+ }
1032
+ const retryInfo = extractRateLimitRetryInfo(streamEvents);
1033
+ await client.errorRun(run.id, errorMsg, {
1034
+ pullRequestUrl,
1035
+ errorType: retryInfo?.errorType,
1036
+ retryAfter: retryInfo?.retryAfterMs
1037
+ });
1038
+ if (retryInfo) {
1039
+ const retryAt = new Date(retryInfo.retryAfterMs).toLocaleString(void 0, {
1040
+ dateStyle: "medium",
1041
+ timeStyle: "short"
1042
+ });
1043
+ log(`${C.red}\u23F8 Rate limited \u2014 server will retry at ${retryAt}${C.reset}`);
1044
+ } else {
1045
+ log(
1046
+ `${C.red}\u274C Run errored${exitCode !== 0 ? ` (exit code ${exitCode})` : " (usage limit)"}${C.reset}`
1047
+ );
1048
+ }
1049
+ for (const line of detail2.split("\n")) {
1050
+ log(`${C.red} ${line}${C.reset}`);
1051
+ }
1052
+ if (config.verbose) {
1053
+ logClaudeRunFailureDiagnostics(log, {
1054
+ exitCode,
1055
+ sessionId,
1056
+ stderr,
1057
+ rawOutput,
1058
+ streamEvents,
1059
+ derivedError: detail2,
1060
+ apiError: errorMsg
1061
+ });
1062
+ }
771
1063
  return;
772
1064
  }
773
1065
  const resultText = extractResultText(stdout);
@@ -777,6 +1069,28 @@ ${stdout.slice(0, 2e3)}
777
1069
  logGreen(`\u2705 Run completed`);
778
1070
  } catch (err) {
779
1071
  const message = err instanceof Error ? err.message : String(err);
1072
+ 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));
1076
+ if (process.env.GCA_DEBUG_CLAUDE_RAW === "1") {
1077
+ logClaudeRawFailureJson({
1078
+ runId: run.id,
1079
+ taskId: run.taskId,
1080
+ taskTitle: run.taskTitle,
1081
+ thrownMessage: message,
1082
+ thrownStack: stack ?? null,
1083
+ exitCode: lastClaudeExitCode,
1084
+ sessionId: lastClaudeSessionId ?? null,
1085
+ note: "Thrown before or after Claude finished; fields may be empty.",
1086
+ stderr: stderrDbg.text,
1087
+ stderrMeta: { fullLength: stderrDbg.fullLength, truncated: stderrDbg.truncated },
1088
+ stdout: stdoutDbg.text,
1089
+ stdoutMeta: { fullLength: stdoutDbg.fullLength, truncated: stdoutDbg.truncated },
1090
+ rawOutput: rawDbg.text,
1091
+ rawOutputMeta: { fullLength: rawDbg.fullLength, truncated: rawDbg.truncated }
1092
+ });
1093
+ }
780
1094
  const errorBody = `Claude Code runner error:
781
1095
  \`\`\`
782
1096
  ${message.slice(0, 2e3)}
@@ -813,6 +1127,25 @@ function loadEnvFile() {
813
1127
  }
814
1128
  }
815
1129
  }
1130
+ function getRunnerPackageInfo() {
1131
+ try {
1132
+ const here = path.dirname(fileURLToPath(import.meta.url));
1133
+ const pkgPath = path.join(here, "..", "package.json");
1134
+ const raw = readFileSync(pkgPath, "utf-8");
1135
+ const pkg = JSON.parse(raw);
1136
+ return {
1137
+ name: pkg.name ?? "@groupchatai/claude-runner",
1138
+ version: pkg.version ?? "unknown"
1139
+ };
1140
+ } catch {
1141
+ return { name: "@groupchatai/claude-runner", version: "unknown" };
1142
+ }
1143
+ }
1144
+ function showVersion() {
1145
+ const { name, version } = getRunnerPackageInfo();
1146
+ console.log(`${name} ${version}`);
1147
+ process.exit(0);
1148
+ }
816
1149
  function showHelp() {
817
1150
  console.log(`
818
1151
  Usage: npx @groupchatai/claude-runner [command] [options]
@@ -833,6 +1166,7 @@ Options:
833
1166
  --token <token> Agent token (or set GCA_TOKEN env var)
834
1167
  --no-worktree Disable git worktree isolation for runs
835
1168
  -h, --help Show this help message
1169
+ -v, -version, --version Print version and exit
836
1170
 
837
1171
  Environment variables:
838
1172
  GCA_TOKEN Agent token (gca_...)
@@ -891,6 +1225,11 @@ function parseArgs() {
891
1225
  case "-h":
892
1226
  showHelp();
893
1227
  break;
1228
+ case "--version":
1229
+ case "-v":
1230
+ case "-version":
1231
+ showVersion();
1232
+ break;
894
1233
  case "--no-worktree":
895
1234
  config.useWorktrees = false;
896
1235
  break;
@@ -1067,13 +1406,22 @@ async function runWithWebSocket(client, config, scheduler) {
1067
1406
  }
1068
1407
  );
1069
1408
  await new Promise((resolve) => {
1070
- const shutdown = () => {
1409
+ function shutdown() {
1410
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1411
+ process.removeListener("SIGINT", shutdown);
1412
+ process.removeListener("SIGTERM", shutdown);
1071
1413
  console.log("\n\u{1F6D1} Shutting down\u2026");
1072
- void convex.close();
1073
- resolve();
1074
- };
1075
- process.on("SIGINT", shutdown);
1076
- process.on("SIGTERM", shutdown);
1414
+ void (async () => {
1415
+ try {
1416
+ await convex.close();
1417
+ } catch {
1418
+ } finally {
1419
+ resolve();
1420
+ }
1421
+ })();
1422
+ }
1423
+ process.once("SIGINT", shutdown);
1424
+ process.once("SIGTERM", shutdown);
1077
1425
  });
1078
1426
  }
1079
1427
  async function runWithPolling(client, config, scheduler) {
@@ -1086,12 +1434,15 @@ async function runWithPolling(client, config, scheduler) {
1086
1434
  `
1087
1435
  );
1088
1436
  let running = true;
1089
- const shutdown = () => {
1437
+ function shutdownPoll() {
1438
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1439
+ process.removeListener("SIGINT", shutdownPoll);
1440
+ process.removeListener("SIGTERM", shutdownPoll);
1090
1441
  console.log("\n\u{1F6D1} Shutting down\u2026");
1091
1442
  running = false;
1092
- };
1093
- process.on("SIGINT", shutdown);
1094
- process.on("SIGTERM", shutdown);
1443
+ }
1444
+ process.once("SIGINT", shutdownPoll);
1445
+ process.once("SIGTERM", shutdownPoll);
1095
1446
  if (config.once) {
1096
1447
  try {
1097
1448
  const pending = await client.listPendingRuns();
@@ -1115,7 +1466,14 @@ async function runWithPolling(client, config, scheduler) {
1115
1466
  await sleep(config.pollInterval);
1116
1467
  }
1117
1468
  }
1469
+ function wantsVersionOnly(argv) {
1470
+ return argv.some((a) => a === "--version" || a === "-v" || a === "-version");
1471
+ }
1118
1472
  async function main() {
1473
+ const argv = process.argv.slice(2);
1474
+ if (wantsVersionOnly(argv)) {
1475
+ showVersion();
1476
+ }
1119
1477
  if (process.argv.includes("cleanup")) {
1120
1478
  loadEnvFile();
1121
1479
  const workDir = process.cwd();
@@ -1153,10 +1511,20 @@ async function main() {
1153
1511
  await runWithWebSocket(client, config, scheduler);
1154
1512
  }
1155
1513
  if (!scheduler.isEmpty()) {
1156
- console.log(`\u23F3 Waiting for ${scheduler.activeTaskCount} in-flight task(s)\u2026`);
1157
- while (!scheduler.isEmpty()) {
1514
+ console.log(
1515
+ `\u23F3 Waiting for ${scheduler.activeTaskCount} in-flight task(s)\u2026 (Ctrl+C to force quit)`
1516
+ );
1517
+ let forceQuit = false;
1518
+ const forceHandler = () => {
1519
+ forceQuit = true;
1520
+ };
1521
+ process.once("SIGINT", forceHandler);
1522
+ process.once("SIGTERM", forceHandler);
1523
+ while (!scheduler.isEmpty() && !forceQuit) {
1158
1524
  await sleep(1e3);
1159
1525
  }
1526
+ process.removeListener("SIGINT", forceHandler);
1527
+ process.removeListener("SIGTERM", forceHandler);
1160
1528
  }
1161
1529
  console.log("\u{1F44B} Goodbye.");
1162
1530
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groupchatai/claude-runner",
3
- "version": "0.4.3",
3
+ "version": "0.4.6",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {