@groupchatai/claude-runner 0.4.3 → 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 +408 -21
  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) {
@@ -682,6 +892,47 @@ Removing ${safe.length} safe worktree(s)\u2026`);
682
892
  }
683
893
  console.log();
684
894
  }
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
+ }
685
936
  async function processRun(client, run, config, worktreeDir, detail) {
686
937
  const runNum = ++runCounter;
687
938
  const runTag = ` ${C.pid}[${runNum}]${C.reset}`;
@@ -716,6 +967,11 @@ async function processRun(client, run, config, worktreeDir, detail) {
716
967
  }
717
968
  log("\u25B6 Run started");
718
969
  const effectiveCwd = worktreeDir ?? config.workDir;
970
+ let lastClaudeStderr = "";
971
+ let lastClaudeStdout = "";
972
+ let lastClaudeRawOutput = "";
973
+ let lastClaudeExitCode = null;
974
+ let lastClaudeSessionId;
719
975
  try {
720
976
  if (!worktreeDir && runOptions.branch) {
721
977
  log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
@@ -744,7 +1000,12 @@ async function processRun(client, run, config, worktreeDir, detail) {
744
1000
  effectiveCwd
745
1001
  );
746
1002
  log(`\u{1F916} Claude Code spawned (pid ${child.pid})${isFollowUp ? " (follow-up)" : ""}`);
747
- 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;
748
1009
  if (exitCode !== 0 && isFollowUp) {
749
1010
  log(`\u26A0 Session resume failed, retrying with fresh session\u2026`);
750
1011
  sessionCache.delete(run.taskId);
@@ -753,21 +1014,81 @@ async function processRun(client, run, config, worktreeDir, detail) {
753
1014
  const retryResult = await retry.output;
754
1015
  stdout = retryResult.stdout;
755
1016
  rawOutput = retryResult.rawOutput;
1017
+ stderr = retryResult.stderr;
756
1018
  exitCode = retryResult.exitCode;
757
1019
  sessionId = retryResult.sessionId;
758
1020
  streamPrUrl = retryResult.streamPrUrl;
1021
+ lastClaudeStderr = stderr;
1022
+ lastClaudeStdout = stdout;
1023
+ lastClaudeRawOutput = rawOutput;
1024
+ lastClaudeExitCode = exitCode;
1025
+ lastClaudeSessionId = sessionId;
759
1026
  }
760
1027
  if (sessionId) {
761
1028
  sessionCache.set(run.taskId, sessionId);
762
1029
  }
763
1030
  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}`);
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
+ }
771
1092
  return;
772
1093
  }
773
1094
  const resultText = extractResultText(stdout);
@@ -777,6 +1098,28 @@ ${stdout.slice(0, 2e3)}
777
1098
  logGreen(`\u2705 Run completed`);
778
1099
  } catch (err) {
779
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
+ }
780
1123
  const errorBody = `Claude Code runner error:
781
1124
  \`\`\`
782
1125
  ${message.slice(0, 2e3)}
@@ -813,6 +1156,25 @@ function loadEnvFile() {
813
1156
  }
814
1157
  }
815
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
+ }
816
1178
  function showHelp() {
817
1179
  console.log(`
818
1180
  Usage: npx @groupchatai/claude-runner [command] [options]
@@ -833,6 +1195,7 @@ Options:
833
1195
  --token <token> Agent token (or set GCA_TOKEN env var)
834
1196
  --no-worktree Disable git worktree isolation for runs
835
1197
  -h, --help Show this help message
1198
+ -v, -version, --version Print version and exit
836
1199
 
837
1200
  Environment variables:
838
1201
  GCA_TOKEN Agent token (gca_...)
@@ -891,6 +1254,11 @@ function parseArgs() {
891
1254
  case "-h":
892
1255
  showHelp();
893
1256
  break;
1257
+ case "--version":
1258
+ case "-v":
1259
+ case "-version":
1260
+ showVersion();
1261
+ break;
894
1262
  case "--no-worktree":
895
1263
  config.useWorktrees = false;
896
1264
  break;
@@ -1067,13 +1435,22 @@ async function runWithWebSocket(client, config, scheduler) {
1067
1435
  }
1068
1436
  );
1069
1437
  await new Promise((resolve) => {
1070
- const shutdown = () => {
1438
+ function shutdown() {
1439
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1440
+ process.removeListener("SIGINT", shutdown);
1441
+ process.removeListener("SIGTERM", shutdown);
1071
1442
  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);
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);
1077
1454
  });
1078
1455
  }
1079
1456
  async function runWithPolling(client, config, scheduler) {
@@ -1086,12 +1463,15 @@ async function runWithPolling(client, config, scheduler) {
1086
1463
  `
1087
1464
  );
1088
1465
  let running = true;
1089
- const shutdown = () => {
1466
+ function shutdownPoll() {
1467
+ if (!tryBeginClaudeRunnerSignalTeardown()) return;
1468
+ process.removeListener("SIGINT", shutdownPoll);
1469
+ process.removeListener("SIGTERM", shutdownPoll);
1090
1470
  console.log("\n\u{1F6D1} Shutting down\u2026");
1091
1471
  running = false;
1092
- };
1093
- process.on("SIGINT", shutdown);
1094
- process.on("SIGTERM", shutdown);
1472
+ }
1473
+ process.once("SIGINT", shutdownPoll);
1474
+ process.once("SIGTERM", shutdownPoll);
1095
1475
  if (config.once) {
1096
1476
  try {
1097
1477
  const pending = await client.listPendingRuns();
@@ -1115,7 +1495,14 @@ async function runWithPolling(client, config, scheduler) {
1115
1495
  await sleep(config.pollInterval);
1116
1496
  }
1117
1497
  }
1498
+ function wantsVersionOnly(argv) {
1499
+ return argv.some((a) => a === "--version" || a === "-v" || a === "-version");
1500
+ }
1118
1501
  async function main() {
1502
+ const argv = process.argv.slice(2);
1503
+ if (wantsVersionOnly(argv)) {
1504
+ showVersion();
1505
+ }
1119
1506
  if (process.argv.includes("cleanup")) {
1120
1507
  loadEnvFile();
1121
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.3",
3
+ "version": "0.4.5",
4
4
  "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {