@groupchatai/claude-runner 0.1.0

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 (3) hide show
  1. package/README.md +54 -0
  2. package/dist/index.js +633 -0
  3. package/package.json +48 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @groupchatai/claude-runner
2
+
3
+ Run [GroupChat AI](https://groupchat.ai) agent tasks locally with [Claude Code](https://docs.anthropic.com/en/docs/claude-code).
4
+
5
+ Polls for pending runs assigned to your agent, spawns `claude -p` for each, and reports results back through the REST API. Supports both WebSocket (real-time) and HTTP polling modes.
6
+
7
+ ## Prerequisites
8
+
9
+ - Node.js ≥ 20
10
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and authenticated
11
+ - A GroupChat AI agent token (`GCA_TOKEN`)
12
+
13
+ ## Quick Start
14
+
15
+ ```bash
16
+ # From your repo directory:
17
+ npx @groupchatai/claude-runner --token gca_your_token_here
18
+ ```
19
+
20
+ Or set the token in `.env.local`:
21
+
22
+ ```env
23
+ GCA_TOKEN=gca_your_token_here
24
+ ```
25
+
26
+ Then just run:
27
+
28
+ ```bash
29
+ npx @groupchatai/claude-runner
30
+ ```
31
+
32
+ ## Options
33
+
34
+ | Flag | Description | Default |
35
+ | ---------------------- | ------------------------------------------------ | ------- |
36
+ | `--token <token>` | Agent token (overrides `GCA_TOKEN` env var) | — |
37
+ | `--work-dir <path>` | Repo directory for Claude Code to work in | `cwd` |
38
+ | `--poll` | Use HTTP polling instead of WebSocket | `false` |
39
+ | `--poll-interval <ms>` | Polling interval in milliseconds (with `--poll`) | `5000` |
40
+ | `--max-concurrent <n>` | Max concurrent runs | `1` |
41
+ | `--model <model>` | Claude model to use | — |
42
+ | `--dry-run` | Poll and log runs without executing Claude Code | `false` |
43
+ | `--once` | Process one batch of pending runs and exit | `false` |
44
+ | `--verbose` | Stream Claude Code activity with pid | `false` |
45
+
46
+ ## WebSocket vs Polling
47
+
48
+ By default, the runner uses a WebSocket connection for real-time task delivery (requires the `convex` package). If `convex` is not installed, it falls back to HTTP polling automatically.
49
+
50
+ To force polling mode, use `--poll`.
51
+
52
+ ## License
53
+
54
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,633 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spawn } from "child_process";
5
+ import { readFileSync } from "fs";
6
+ import path from "path";
7
+ var API_URL = "https://groupchat.ai";
8
+ var CONVEX_URL = "https://fantastic-jay-464.convex.cloud";
9
+ var GroupChatAgentClient = class {
10
+ baseUrl;
11
+ token;
12
+ constructor(apiUrl, token) {
13
+ this.baseUrl = `${apiUrl.replace(/\/$/, "")}/api/v1/agent`;
14
+ this.token = token;
15
+ }
16
+ async request(method, endpoint, body) {
17
+ const url = `${this.baseUrl}${endpoint}`;
18
+ const res = await fetch(url, {
19
+ method,
20
+ headers: {
21
+ Authorization: `Bearer ${this.token}`,
22
+ "Content-Type": "application/json"
23
+ },
24
+ body: body ? JSON.stringify(body) : void 0,
25
+ redirect: "follow"
26
+ });
27
+ if (res.status === 401 && res.redirected) {
28
+ const retry = await fetch(res.url, {
29
+ method,
30
+ headers: {
31
+ Authorization: `Bearer ${this.token}`,
32
+ "Content-Type": "application/json"
33
+ },
34
+ body: body ? JSON.stringify(body) : void 0
35
+ });
36
+ if (!retry.ok) {
37
+ const text = await retry.text().catch(() => "");
38
+ throw new Error(`API ${method} ${endpoint} \u2192 ${retry.status}: ${text}`);
39
+ }
40
+ return await retry.json();
41
+ }
42
+ if (!res.ok) {
43
+ const text = await res.text().catch(() => "");
44
+ throw new Error(`API ${method} ${endpoint} \u2192 ${res.status}: ${text}`);
45
+ }
46
+ return await res.json();
47
+ }
48
+ async getMe() {
49
+ return this.request("GET", "/me");
50
+ }
51
+ async listPendingRuns() {
52
+ const data = await this.request("GET", "/runs?status=PENDING");
53
+ return data.runs;
54
+ }
55
+ async getRunDetail(runId) {
56
+ return this.request("GET", `/runs/${runId}`);
57
+ }
58
+ async startRun(runId, body) {
59
+ return this.request("POST", `/runs/${runId}/start`, { body });
60
+ }
61
+ async completeRun(runId, body, opts) {
62
+ return this.request("POST", `/runs/${runId}/complete`, { body, ...opts });
63
+ }
64
+ async errorRun(runId, body, opts) {
65
+ return this.request("POST", `/runs/${runId}/error`, { body, ...opts });
66
+ }
67
+ async postComment(runId, body) {
68
+ return this.request("POST", `/runs/${runId}/comment`, { body });
69
+ }
70
+ };
71
+ function buildClaudePrompt(detail) {
72
+ const parts = [];
73
+ parts.push(`# Task: ${detail.task.title}`);
74
+ if (detail.task.description) {
75
+ parts.push(`
76
+ ## Description
77
+ ${detail.task.description}`);
78
+ }
79
+ if (detail.prompt && detail.prompt !== detail.task.title) {
80
+ parts.push(`
81
+ ## Instructions
82
+ ${detail.prompt}`);
83
+ }
84
+ if (detail.task.images.length > 0) {
85
+ parts.push(`
86
+ ## Attached Images`);
87
+ for (const url of detail.task.images) {
88
+ parts.push(`- ${url}`);
89
+ }
90
+ }
91
+ const comments = detail.activity.filter(
92
+ (a) => a.type === "comment" && a.body && a.body.trim().length > 0
93
+ );
94
+ if (comments.length > 0) {
95
+ parts.push(`
96
+ ## Conversation History`);
97
+ for (const comment of comments) {
98
+ const author = comment.userName ?? "Someone";
99
+ parts.push(
100
+ `
101
+ **${author}** (${new Date(comment.createdAt).toLocaleString()}):
102
+ ${comment.body}`
103
+ );
104
+ }
105
+ }
106
+ if (detail.task.dueDate) {
107
+ parts.push(`
108
+ Due: ${new Date(detail.task.dueDate).toLocaleDateString()}`);
109
+ }
110
+ parts.push(
111
+ [
112
+ "\n---",
113
+ "RULES:",
114
+ "- You may create PRs with `gh pr create`. You may push branches and contribute to existing PRs.",
115
+ "- NEVER run `gh pr merge`. Do NOT merge any PR.",
116
+ "- NEVER run `gh pr close`. Do NOT close any PR.",
117
+ "- NEVER run `git push --force` or `git push -f`. No force pushes.",
118
+ "- When you are done, provide a clear summary of what you accomplished. If you created a PR or made commits, include the PR URL and branch name."
119
+ ].join("\n")
120
+ );
121
+ return parts.join("\n");
122
+ }
123
+ function formatStreamEvent(event, pid) {
124
+ const prefix = ` [pid ${pid}]`;
125
+ switch (event.type) {
126
+ case "system":
127
+ if (event.subtype === "init") {
128
+ let line = `${prefix} session started`;
129
+ if (event.session_id) line += ` (${event.session_id})`;
130
+ return line;
131
+ }
132
+ return null;
133
+ case "assistant": {
134
+ const blocks = event.message?.content;
135
+ if (!blocks || blocks.length === 0) return null;
136
+ const parts = [];
137
+ for (const block of blocks) {
138
+ if (block.type === "text" && block.text) {
139
+ for (const line of block.text.split("\n")) {
140
+ if (line.trim()) parts.push(`${prefix} \u{1F4AC} ${line}`);
141
+ }
142
+ } else if (block.type === "tool_use" && block.name) {
143
+ let detail = block.name;
144
+ const input = block.input;
145
+ if (input) {
146
+ if (typeof input.file_path === "string") {
147
+ detail += ` \u2192 ${input.file_path}`;
148
+ } else if (typeof input.command === "string") {
149
+ const cmd = input.command.length > 80 ? input.command.slice(0, 77) + "\u2026" : input.command;
150
+ detail += ` \u2192 ${cmd}`;
151
+ }
152
+ }
153
+ parts.push(`${prefix} \u{1F527} ${detail}`);
154
+ }
155
+ }
156
+ return parts.length > 0 ? parts.join("\n") : null;
157
+ }
158
+ case "tool_result":
159
+ return null;
160
+ case "result": {
161
+ const lines = [];
162
+ if (event.cost_usd !== void 0 || event.total_cost_usd !== void 0) {
163
+ const cost = event.cost_usd ?? event.total_cost_usd;
164
+ lines.push(`${prefix} \u{1F4B0} cost: $${cost?.toFixed(4)}`);
165
+ }
166
+ if (event.result) {
167
+ const preview = event.result.length > 120 ? event.result.slice(0, 117) + "\u2026" : event.result;
168
+ lines.push(`${prefix} \u2705 ${preview}`);
169
+ }
170
+ return lines.length > 0 ? lines.join("\n") : null;
171
+ }
172
+ default:
173
+ return null;
174
+ }
175
+ }
176
+ var ALLOWED_TOOLS = [
177
+ "Bash(git:*)",
178
+ "Bash(gh:*)",
179
+ "Bash(npm:*)",
180
+ "Bash(npx:*)",
181
+ "Bash(pnpm:*)",
182
+ "Bash(yarn:*)",
183
+ "Bash(node:*)",
184
+ "Bash(cat:*)",
185
+ "Bash(ls:*)",
186
+ "Bash(find:*)",
187
+ "Bash(grep:*)",
188
+ "Bash(head:*)",
189
+ "Bash(tail:*)",
190
+ "Bash(wc:*)",
191
+ "Bash(sort:*)",
192
+ "Bash(uniq:*)",
193
+ "Bash(diff:*)",
194
+ "Bash(echo:*)",
195
+ "Bash(mkdir:*)",
196
+ "Bash(cp:*)",
197
+ "Bash(mv:*)",
198
+ "Bash(rm:*)",
199
+ "Bash(touch:*)",
200
+ "Bash(sed:*)",
201
+ "Bash(awk:*)",
202
+ "Bash(curl:*)",
203
+ "Bash(pwd:*)",
204
+ "Bash(env:*)",
205
+ "Bash(which:*)",
206
+ "Read",
207
+ "Edit",
208
+ "Write",
209
+ "Glob",
210
+ "Grep",
211
+ "WebFetch",
212
+ "WebSearch",
213
+ "TodoWrite",
214
+ "NotebookEdit"
215
+ ];
216
+ function spawnClaudeCode(prompt, config, runOptions) {
217
+ const format = config.verbose ? "stream-json" : "json";
218
+ const args = [
219
+ "-p",
220
+ prompt,
221
+ "--output-format",
222
+ format,
223
+ "--verbose",
224
+ "--permission-mode",
225
+ "default",
226
+ "--allowedTools",
227
+ ...ALLOWED_TOOLS
228
+ ];
229
+ const model = runOptions?.model ?? config.model;
230
+ if (model) {
231
+ args.push("--model", model);
232
+ }
233
+ const child = spawn("claude", args, {
234
+ cwd: config.workDir,
235
+ stdio: ["ignore", "pipe", "pipe"],
236
+ env: { ...process.env }
237
+ });
238
+ const pid = child.pid ?? 0;
239
+ const output = new Promise(
240
+ (resolve, reject) => {
241
+ const chunks = [];
242
+ const errChunks = [];
243
+ let lineBuf = "";
244
+ let lastResultJson = "";
245
+ child.stdout?.on("data", (data) => {
246
+ chunks.push(data);
247
+ if (!config.verbose) return;
248
+ lineBuf += data.toString("utf-8");
249
+ const lines = lineBuf.split("\n");
250
+ lineBuf = lines.pop() ?? "";
251
+ for (const line of lines) {
252
+ const trimmed = line.trim();
253
+ if (!trimmed) continue;
254
+ try {
255
+ const event = JSON.parse(trimmed);
256
+ if (event.type === "result") lastResultJson = trimmed;
257
+ const formatted = formatStreamEvent(event, pid);
258
+ if (formatted) console.log(formatted);
259
+ } catch {
260
+ console.log(` [pid ${pid}] ${trimmed}`);
261
+ }
262
+ }
263
+ });
264
+ child.stderr?.on("data", (data) => {
265
+ errChunks.push(data);
266
+ if (config.verbose) {
267
+ const text = data.toString("utf-8").trimEnd();
268
+ if (text) console.error(` [pid ${pid}] ${text}`);
269
+ }
270
+ });
271
+ child.on("error", (err) => reject(new Error(`Failed to spawn claude: ${err.message}`)));
272
+ child.on("close", (code) => {
273
+ if (config.verbose && lineBuf.trim()) {
274
+ try {
275
+ const event = JSON.parse(lineBuf.trim());
276
+ if (event.type === "result") lastResultJson = lineBuf.trim();
277
+ const formatted = formatStreamEvent(event, pid);
278
+ if (formatted) console.log(formatted);
279
+ } catch {
280
+ console.log(` [pid ${pid}] ${lineBuf.trim()}`);
281
+ }
282
+ }
283
+ const rawOutput = Buffer.concat(chunks).toString("utf-8");
284
+ const stdout = config.verbose ? lastResultJson || rawOutput : rawOutput;
285
+ const stderr = Buffer.concat(errChunks).toString("utf-8");
286
+ if (code !== 0 && stdout.trim().length === 0) {
287
+ reject(new Error(`claude exited with code ${code}: ${stderr}`));
288
+ } else {
289
+ resolve({ stdout, rawOutput, exitCode: code ?? 0 });
290
+ }
291
+ });
292
+ }
293
+ );
294
+ return { process: child, output };
295
+ }
296
+ function findResultEvent(stdout) {
297
+ try {
298
+ const parsed = JSON.parse(stdout);
299
+ if (Array.isArray(parsed)) {
300
+ for (let i = parsed.length - 1; i >= 0; i--) {
301
+ const item = parsed[i];
302
+ if (item && item.type === "result") return item;
303
+ }
304
+ return null;
305
+ }
306
+ const single = parsed;
307
+ if (single.type === "result" || single.result !== void 0) return single;
308
+ } catch {
309
+ }
310
+ const lines = stdout.split("\n");
311
+ for (let i = lines.length - 1; i >= 0; i--) {
312
+ const line = lines[i].trim();
313
+ if (!line) continue;
314
+ try {
315
+ const event = JSON.parse(line);
316
+ if (event.type === "result") return event;
317
+ } catch {
318
+ continue;
319
+ }
320
+ }
321
+ return null;
322
+ }
323
+ function extractResultText(stdout) {
324
+ const event = findResultEvent(stdout);
325
+ if (!event) return stdout;
326
+ if (typeof event.result === "string") return event.result;
327
+ if (typeof event.text === "string") return event.text;
328
+ return stdout;
329
+ }
330
+ function extractCost(stdout) {
331
+ const event = findResultEvent(stdout);
332
+ if (!event) return void 0;
333
+ const cost = typeof event.cost_usd === "number" ? event.cost_usd : typeof event.total_cost_usd === "number" ? event.total_cost_usd : void 0;
334
+ if (cost !== void 0 && cost > 0) {
335
+ return { stepCostUsd: cost };
336
+ }
337
+ return void 0;
338
+ }
339
+ var GITHUB_PR_URL_RE = /https:\/\/github\.com\/[^\s"'<>]+\/pull\/\d+/g;
340
+ function extractPullRequestUrl(stdout) {
341
+ const event = findResultEvent(stdout);
342
+ if (event) {
343
+ const text = typeof event.result === "string" ? event.result : typeof event.text === "string" ? event.text : "";
344
+ const match2 = text.match(GITHUB_PR_URL_RE);
345
+ if (match2) return match2[match2.length - 1];
346
+ }
347
+ const match = stdout.match(GITHUB_PR_URL_RE);
348
+ if (match) return match[match.length - 1];
349
+ return void 0;
350
+ }
351
+ async function processRun(client, run, config) {
352
+ const log = (msg) => console.log(` [${run.id.slice(-8)}] ${msg}`);
353
+ const detail = await client.getRunDetail(run.id);
354
+ const ownerName = run.owner?.name ?? detail.owner?.name ?? "unknown";
355
+ log(`\u{1F4CB} "${run.taskTitle}" \u2014 delegated by ${ownerName}`);
356
+ if (detail.status !== "PENDING") {
357
+ log(`\u23ED Run is no longer PENDING (now ${detail.status}), skipping.`);
358
+ return;
359
+ }
360
+ const prompt = buildClaudePrompt(detail);
361
+ if (config.dryRun) {
362
+ log("\u{1F3DC} DRY RUN \u2014 would execute Claude Code with prompt:");
363
+ console.log("---");
364
+ console.log(prompt.slice(0, 500) + (prompt.length > 500 ? "\n..." : ""));
365
+ console.log("---");
366
+ return;
367
+ }
368
+ const runOptions = detail.providerOptions ?? {};
369
+ const effectiveModel = runOptions.model ?? config.model;
370
+ const startMsg = effectiveModel ? `Starting work with Claude Code (${effectiveModel})\u2026` : "Starting work with Claude Code\u2026";
371
+ try {
372
+ await client.startRun(run.id, startMsg);
373
+ } catch (err) {
374
+ const msg = err instanceof Error ? err.message : String(err);
375
+ if (msg.includes("not pending") || msg.includes("not PENDING") || msg.includes("400")) {
376
+ log(`\u23ED Run was already claimed, skipping.`);
377
+ return;
378
+ }
379
+ throw err;
380
+ }
381
+ log("\u25B6 Run started");
382
+ try {
383
+ if (runOptions.branch) {
384
+ log(`\u{1F33F} Checking out branch: ${runOptions.branch}`);
385
+ const git = spawn("git", ["checkout", runOptions.branch], {
386
+ cwd: config.workDir,
387
+ stdio: "pipe"
388
+ });
389
+ await new Promise((resolve) => git.on("close", () => resolve()));
390
+ }
391
+ if (config.verbose) {
392
+ log(`\u{1F4DD} Prompt (${prompt.length} chars)`);
393
+ if (effectiveModel) log(`\u{1F9E0} Model: ${effectiveModel}`);
394
+ }
395
+ const { process: child, output } = spawnClaudeCode(prompt, config, runOptions);
396
+ log(`\u{1F916} Claude Code spawned (pid ${child.pid})`);
397
+ const { stdout, rawOutput, exitCode } = await output;
398
+ const pullRequestUrl = extractPullRequestUrl(stdout) ?? extractPullRequestUrl(rawOutput);
399
+ if (exitCode !== 0) {
400
+ const errorMsg = `Claude Code exited with code ${exitCode}:
401
+ \`\`\`
402
+ ${stdout.slice(0, 2e3)}
403
+ \`\`\``;
404
+ await client.errorRun(run.id, errorMsg, { pullRequestUrl });
405
+ log(`\u274C Run errored (exit code ${exitCode})`);
406
+ return;
407
+ }
408
+ const resultText = extractResultText(stdout);
409
+ const cost = extractCost(stdout);
410
+ const summary = resultText;
411
+ await client.completeRun(run.id, summary, { ...cost, pullRequestUrl });
412
+ if (pullRequestUrl) log(`\u{1F517} PR: ${pullRequestUrl}`);
413
+ log(`\u2705 Run completed`);
414
+ } catch (err) {
415
+ const message = err instanceof Error ? err.message : String(err);
416
+ const errorBody = `Claude Code runner error:
417
+ \`\`\`
418
+ ${message.slice(0, 2e3)}
419
+ \`\`\``;
420
+ try {
421
+ await client.errorRun(run.id, errorBody);
422
+ } catch {
423
+ log(`\u26A0 Failed to report error to API`);
424
+ }
425
+ log(`\u274C Error: ${message}`);
426
+ }
427
+ }
428
+ function loadEnvFile() {
429
+ const candidates = [".env.local", ".env"];
430
+ for (const file of candidates) {
431
+ try {
432
+ const contents = readFileSync(path.resolve(file), "utf-8");
433
+ for (const line of contents.split("\n")) {
434
+ const trimmed = line.trim();
435
+ if (!trimmed || trimmed.startsWith("#")) continue;
436
+ const eqIdx = trimmed.indexOf("=");
437
+ if (eqIdx === -1) continue;
438
+ const key = trimmed.slice(0, eqIdx).trim();
439
+ let value = trimmed.slice(eqIdx + 1).trim();
440
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
441
+ value = value.slice(1, -1);
442
+ }
443
+ if (!process.env[key]) {
444
+ process.env[key] = value;
445
+ }
446
+ }
447
+ break;
448
+ } catch {
449
+ }
450
+ }
451
+ }
452
+ function parseArgs() {
453
+ loadEnvFile();
454
+ const args = process.argv.slice(2);
455
+ const config = {
456
+ token: process.env.GCA_TOKEN ?? "",
457
+ apiUrl: process.env.GCA_API_URL ?? API_URL,
458
+ convexUrl: process.env.GCA_CONVEX_URL ?? CONVEX_URL,
459
+ workDir: process.cwd(),
460
+ pollInterval: 5e3,
461
+ maxConcurrent: 1,
462
+ poll: false,
463
+ dryRun: false,
464
+ once: false,
465
+ verbose: false
466
+ };
467
+ for (let i = 0; i < args.length; i++) {
468
+ const arg = args[i];
469
+ switch (arg) {
470
+ case "--work-dir":
471
+ config.workDir = path.resolve(args[++i] ?? ".");
472
+ break;
473
+ case "--poll-interval":
474
+ config.pollInterval = parseInt(args[++i] ?? "5000", 10);
475
+ break;
476
+ case "--max-concurrent":
477
+ config.maxConcurrent = parseInt(args[++i] ?? "1", 10);
478
+ break;
479
+ case "--model":
480
+ config.model = args[++i];
481
+ break;
482
+ case "--poll":
483
+ config.poll = true;
484
+ break;
485
+ case "--dry-run":
486
+ config.dryRun = true;
487
+ break;
488
+ case "--once":
489
+ config.once = true;
490
+ break;
491
+ case "--verbose":
492
+ config.verbose = true;
493
+ break;
494
+ case "--token":
495
+ config.token = args[++i] ?? "";
496
+ break;
497
+ default:
498
+ console.error(`Unknown argument: ${arg}`);
499
+ process.exit(1);
500
+ }
501
+ }
502
+ if (!config.token) {
503
+ console.error("Error: No agent token found.");
504
+ console.error(" Add GCA_TOKEN=gca_... to .env.local, or pass --token gca_...");
505
+ process.exit(1);
506
+ }
507
+ if (!config.token.startsWith("gca_")) {
508
+ console.error(`Error: Token must start with gca_ (got "${config.token.slice(0, 8)}\u2026")`);
509
+ process.exit(1);
510
+ }
511
+ return config;
512
+ }
513
+ async function sleep(ms) {
514
+ return new Promise((resolve) => setTimeout(resolve, ms));
515
+ }
516
+ function handlePendingRuns(runs, activeRuns, client, config) {
517
+ if (runs.length > 0 && config.verbose) {
518
+ console.log(`\u{1F4EC} ${runs.length} pending run(s)`);
519
+ }
520
+ for (const run of runs) {
521
+ if (activeRuns.has(run.id)) continue;
522
+ if (activeRuns.size >= config.maxConcurrent) {
523
+ if (config.verbose) {
524
+ console.log(`\u23F8 At concurrency limit (${config.maxConcurrent}), waiting\u2026`);
525
+ }
526
+ break;
527
+ }
528
+ activeRuns.add(run.id);
529
+ processRun(client, run, config).catch((err) => console.error(`Unhandled error processing run ${run.id}:`, err)).finally(() => activeRuns.delete(run.id));
530
+ }
531
+ }
532
+ async function runWithWebSocket(client, config, activeRuns) {
533
+ let ConvexClient;
534
+ let anyApi;
535
+ try {
536
+ ({ ConvexClient } = await import("convex/browser"));
537
+ ({ anyApi } = await import("convex/server"));
538
+ } catch {
539
+ console.warn("\u26A0 convex package not found \u2014 falling back to HTTP polling.");
540
+ console.warn(" Install convex for WebSocket mode: npm i convex\n");
541
+ await runWithPolling(client, config, activeRuns);
542
+ return;
543
+ }
544
+ const convex = new ConvexClient(config.convexUrl);
545
+ console.log("\u26A1 Connected via WebSocket \u2014 listening for tasks\u2026\n");
546
+ convex.onUpdate(
547
+ anyApi.agentWebSocket.pendingRuns,
548
+ { token: config.token },
549
+ (runs) => {
550
+ if (!runs || runs.length === 0) return;
551
+ handlePendingRuns(runs, activeRuns, client, config);
552
+ }
553
+ );
554
+ await new Promise((resolve) => {
555
+ const shutdown = () => {
556
+ console.log("\n\u{1F6D1} Shutting down\u2026");
557
+ void convex.close();
558
+ resolve();
559
+ };
560
+ process.on("SIGINT", shutdown);
561
+ process.on("SIGTERM", shutdown);
562
+ });
563
+ }
564
+ async function runWithPolling(client, config, activeRuns) {
565
+ console.log(`\u{1F4E1} Polling every ${config.pollInterval}ms \u2014 listening for tasks\u2026
566
+ `);
567
+ let running = true;
568
+ const shutdown = () => {
569
+ console.log("\n\u{1F6D1} Shutting down\u2026");
570
+ running = false;
571
+ };
572
+ process.on("SIGINT", shutdown);
573
+ process.on("SIGTERM", shutdown);
574
+ if (config.once) {
575
+ try {
576
+ const pending = await client.listPendingRuns();
577
+ handlePendingRuns(pending, activeRuns, client, config);
578
+ } catch (err) {
579
+ console.error(`Poll error: ${err instanceof Error ? err.message : err}`);
580
+ }
581
+ while (activeRuns.size > 0) await sleep(1e3);
582
+ return;
583
+ }
584
+ while (running) {
585
+ try {
586
+ const pending = await client.listPendingRuns();
587
+ handlePendingRuns(pending, activeRuns, client, config);
588
+ } catch (err) {
589
+ const msg = err instanceof Error ? err.message : String(err);
590
+ if (config.verbose || !msg.includes("fetch")) {
591
+ console.error(`Poll error: ${msg}`);
592
+ }
593
+ }
594
+ await sleep(config.pollInterval);
595
+ }
596
+ }
597
+ async function main() {
598
+ const config = parseArgs();
599
+ const client = new GroupChatAgentClient(config.apiUrl, config.token);
600
+ let me;
601
+ try {
602
+ me = await client.getMe();
603
+ } catch (err) {
604
+ console.error("Failed to authenticate:", err instanceof Error ? err.message : err);
605
+ process.exit(1);
606
+ }
607
+ console.log(`
608
+ \u{1F916} Agent Runner \u2014 ${me.name}`);
609
+ console.log(` Owner: ${me.ownerName}`);
610
+ console.log(` Work: ${config.workDir}`);
611
+ console.log(` Mode: ${config.poll ? "polling" : "websocket"}`);
612
+ if (config.apiUrl !== API_URL) console.log(` API: ${config.apiUrl}`);
613
+ if (config.model) console.log(` Model: ${config.model}`);
614
+ if (config.dryRun) console.log(` Mode: DRY RUN`);
615
+ console.log();
616
+ const activeRuns = /* @__PURE__ */ new Set();
617
+ if (config.poll || config.once) {
618
+ await runWithPolling(client, config, activeRuns);
619
+ } else {
620
+ await runWithWebSocket(client, config, activeRuns);
621
+ }
622
+ if (activeRuns.size > 0) {
623
+ console.log(`\u23F3 Waiting for ${activeRuns.size} in-flight run(s)\u2026`);
624
+ while (activeRuns.size > 0) {
625
+ await sleep(1e3);
626
+ }
627
+ }
628
+ console.log("\u{1F44B} Goodbye.");
629
+ }
630
+ main().catch((err) => {
631
+ console.error("Fatal error:", err);
632
+ process.exit(1);
633
+ });
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@groupchatai/claude-runner",
3
+ "version": "0.1.0",
4
+ "description": "Run GroupChat AI agent tasks locally with Claude Code",
5
+ "type": "module",
6
+ "bin": {
7
+ "claude-runner": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "prepublishOnly": "npm run build"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "https://github.com/la-agency/groupchat.git",
22
+ "directory": "packages/claude-runner"
23
+ },
24
+ "homepage": "https://groupchat.ai",
25
+ "bugs": {
26
+ "url": "https://github.com/la-agency/groupchat/issues"
27
+ },
28
+ "optionalDependencies": {
29
+ "convex": "^1.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.9.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "license": "MIT",
39
+ "keywords": [
40
+ "groupchat",
41
+ "groupchatai",
42
+ "agent",
43
+ "claude-runner",
44
+ "claude",
45
+ "claude-code",
46
+ "ai"
47
+ ]
48
+ }