opencara 0.10.0 → 0.11.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 (2) hide show
  1. package/dist/index.js +570 -162
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -6,6 +6,30 @@ import { Command as Command2 } from "commander";
6
6
  // src/commands/agent.ts
7
7
  import { Command } from "commander";
8
8
  import crypto from "crypto";
9
+ import * as fs4 from "fs";
10
+ import * as path4 from "path";
11
+
12
+ // ../shared/dist/types.js
13
+ function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
14
+ if (!repoConfig)
15
+ return true;
16
+ const fullRepo = `${targetOwner}/${targetRepo}`;
17
+ switch (repoConfig.mode) {
18
+ case "all":
19
+ return true;
20
+ case "own":
21
+ return agentOwner === targetOwner;
22
+ case "whitelist":
23
+ return (repoConfig.list ?? []).includes(fullRepo);
24
+ case "blacklist":
25
+ return !(repoConfig.list ?? []).includes(fullRepo);
26
+ default:
27
+ return true;
28
+ }
29
+ }
30
+
31
+ // ../shared/dist/review-config.js
32
+ import { parse as parseYaml } from "yaml";
9
33
 
10
34
  // src/config.ts
11
35
  import * as fs from "fs";
@@ -16,17 +40,7 @@ var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
16
40
  var CONFIG_DIR = path.join(os.homedir(), ".opencara");
17
41
  var CONFIG_FILE = process.env.OPENCARA_CONFIG && process.env.OPENCARA_CONFIG.trim() ? path.resolve(process.env.OPENCARA_CONFIG) : path.join(CONFIG_DIR, "config.yml");
18
42
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
19
- function parseLimits(data) {
20
- const raw = data.limits;
21
- if (!raw || typeof raw !== "object") return null;
22
- const obj = raw;
23
- const limits = {};
24
- if (typeof obj.tokens_per_day === "number") limits.tokens_per_day = obj.tokens_per_day;
25
- if (typeof obj.tokens_per_month === "number") limits.tokens_per_month = obj.tokens_per_month;
26
- if (typeof obj.reviews_per_day === "number") limits.reviews_per_day = obj.reviews_per_day;
27
- if (Object.keys(limits).length === 0) return null;
28
- return limits;
29
- }
43
+ var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
30
44
  var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
31
45
  var REPO_PATTERN = /^[^/]+\/[^/]+$/;
32
46
  var RepoConfigError = class extends Error {
@@ -91,8 +105,8 @@ function parseAgents(data) {
91
105
  if (typeof obj.command === "string") agent.command = obj.command;
92
106
  if (obj.router === true) agent.router = true;
93
107
  if (obj.review_only === true) agent.review_only = true;
94
- const agentLimits = parseLimits(obj);
95
- if (agentLimits) agent.limits = agentLimits;
108
+ if (typeof obj.github_token === "string") agent.github_token = obj.github_token;
109
+ if (typeof obj.codebase_dir === "string") agent.codebase_dir = obj.codebase_dir;
96
110
  const repoConfig = parseRepoConfig(obj, i);
97
111
  if (repoConfig) agent.repos = repoConfig;
98
112
  agents.push(agent);
@@ -100,11 +114,13 @@ function parseAgents(data) {
100
114
  return agents;
101
115
  }
102
116
  function loadConfig() {
117
+ const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
103
118
  const defaults = {
104
- apiKey: null,
105
- platformUrl: DEFAULT_PLATFORM_URL,
119
+ platformUrl: envPlatformUrl || DEFAULT_PLATFORM_URL,
106
120
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
107
- limits: null,
121
+ maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
122
+ githubToken: null,
123
+ codebaseDir: null,
108
124
  agentCommand: null,
109
125
  agents: null
110
126
  };
@@ -117,20 +133,136 @@ function loadConfig() {
117
133
  return defaults;
118
134
  }
119
135
  return {
120
- apiKey: typeof data.api_key === "string" ? data.api_key : null,
121
- platformUrl: typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL,
136
+ platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
122
137
  maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
123
- limits: parseLimits(data),
138
+ maxConsecutiveErrors: typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS,
139
+ githubToken: typeof data.github_token === "string" ? data.github_token : null,
140
+ codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
124
141
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
125
142
  agents: parseAgents(data)
126
143
  };
127
144
  }
128
- function resolveAgentLimits(agentLimits, globalLimits) {
129
- if (!agentLimits && !globalLimits) return null;
130
- if (!agentLimits) return globalLimits;
131
- if (!globalLimits) return agentLimits;
132
- const merged = { ...globalLimits, ...agentLimits };
133
- return Object.keys(merged).length === 0 ? null : merged;
145
+ function resolveGithubToken(agentToken, globalToken) {
146
+ return agentToken ? agentToken : globalToken;
147
+ }
148
+ function resolveCodebaseDir(agentDir, globalDir) {
149
+ const raw = agentDir || globalDir;
150
+ if (!raw) return null;
151
+ if (raw.startsWith("~/") || raw === "~") {
152
+ return path.join(os.homedir(), raw.slice(1));
153
+ }
154
+ return path.resolve(raw);
155
+ }
156
+
157
+ // src/codebase.ts
158
+ import { execFileSync } from "child_process";
159
+ import * as fs2 from "fs";
160
+ import * as path2 from "path";
161
+
162
+ // src/sanitize.ts
163
+ var GITHUB_TOKEN_PATTERN = /\b(ghp_[A-Za-z0-9_]{1,255}|gho_[A-Za-z0-9_]{1,255}|ghs_[A-Za-z0-9_]{1,255}|ghr_[A-Za-z0-9_]{1,255}|github_pat_[A-Za-z0-9_]{1,255})\b/g;
164
+ var EMBEDDED_TOKEN_PATTERN = /x-access-token:[^@\s]+@/g;
165
+ var AUTH_HEADER_PATTERN = /(Authorization:)\s*(?:token|Bearer)\s+[^\s,;'"]+/gi;
166
+ function sanitizeTokens(input) {
167
+ return input.replace(GITHUB_TOKEN_PATTERN, "***").replace(EMBEDDED_TOKEN_PATTERN, "x-access-token:***@").replace(AUTH_HEADER_PATTERN, "$1 ***");
168
+ }
169
+
170
+ // src/codebase.ts
171
+ var VALID_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
172
+ function cloneOrUpdate(owner, repo, prNumber, baseDir, githubToken, taskId) {
173
+ validatePathSegment(owner, "owner");
174
+ validatePathSegment(repo, "repo");
175
+ if (taskId) {
176
+ validatePathSegment(taskId, "taskId");
177
+ }
178
+ const repoDir = taskId ? path2.join(baseDir, owner, repo, taskId) : path2.join(baseDir, owner, repo);
179
+ const cloneUrl = buildCloneUrl(owner, repo, githubToken);
180
+ let cloned = false;
181
+ if (!fs2.existsSync(path2.join(repoDir, ".git"))) {
182
+ fs2.mkdirSync(repoDir, { recursive: true });
183
+ git(["clone", "--depth", "1", cloneUrl, repoDir]);
184
+ cloned = true;
185
+ }
186
+ git(["fetch", "--force", "--depth", "1", "origin", `pull/${prNumber}/head`], repoDir);
187
+ git(["checkout", "FETCH_HEAD"], repoDir);
188
+ return { localPath: repoDir, cloned };
189
+ }
190
+ function cleanupTaskDir(dirPath) {
191
+ if (!path2.isAbsolute(dirPath) || dirPath.split(path2.sep).filter(Boolean).length < 3) {
192
+ return;
193
+ }
194
+ try {
195
+ fs2.rmSync(dirPath, { recursive: true, force: true });
196
+ } catch (err) {
197
+ if (err?.code !== "ENOENT") {
198
+ console.warn(`[cleanup] Failed to remove ${dirPath}: ${err.message}`);
199
+ }
200
+ }
201
+ }
202
+ function validatePathSegment(segment, name) {
203
+ if (!VALID_NAME_PATTERN.test(segment) || segment === "." || segment === "..") {
204
+ throw new Error(`Invalid ${name}: '${segment}' contains disallowed characters`);
205
+ }
206
+ }
207
+ function buildCloneUrl(owner, repo, githubToken) {
208
+ if (githubToken) {
209
+ return `https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`;
210
+ }
211
+ return `https://github.com/${owner}/${repo}.git`;
212
+ }
213
+ function git(args, cwd) {
214
+ try {
215
+ return execFileSync("git", args, {
216
+ cwd,
217
+ encoding: "utf-8",
218
+ timeout: 12e4,
219
+ stdio: ["ignore", "pipe", "pipe"]
220
+ });
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ throw new Error(sanitizeTokens(message));
224
+ }
225
+ }
226
+
227
+ // src/github-auth.ts
228
+ import { execSync } from "child_process";
229
+ function getGhCliToken() {
230
+ try {
231
+ const result = execSync("gh auth token", {
232
+ timeout: 5e3,
233
+ encoding: "utf-8",
234
+ stdio: ["ignore", "pipe", "ignore"]
235
+ });
236
+ const token = result.trim();
237
+ return token.length > 0 ? token : null;
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+ function resolveGithubToken2(configToken, deps = {}) {
243
+ const getEnv = deps.getEnv ?? ((key) => process.env[key]);
244
+ const getGhToken = deps.getGhToken ?? getGhCliToken;
245
+ const envToken = getEnv("GITHUB_TOKEN");
246
+ if (envToken) {
247
+ return { token: envToken, method: "env" };
248
+ }
249
+ const ghToken = getGhToken();
250
+ if (ghToken) {
251
+ return { token: ghToken, method: "gh-cli" };
252
+ }
253
+ if (configToken) {
254
+ return { token: configToken, method: "config" };
255
+ }
256
+ return { token: null, method: "none" };
257
+ }
258
+ var AUTH_LOG_MESSAGES = {
259
+ env: "GitHub auth: using GITHUB_TOKEN env var",
260
+ "gh-cli": "GitHub auth: using gh CLI token",
261
+ config: "GitHub auth: using config github_token",
262
+ none: "GitHub auth: none (public repos only)"
263
+ };
264
+ function logAuthMethod(method, log) {
265
+ log(AUTH_LOG_MESSAGES[method]);
134
266
  }
135
267
 
136
268
  // src/http.ts
@@ -142,9 +274,8 @@ var HttpError = class extends Error {
142
274
  }
143
275
  };
144
276
  var ApiClient = class {
145
- constructor(baseUrl, apiKey = null, debug) {
277
+ constructor(baseUrl, debug) {
146
278
  this.baseUrl = baseUrl;
147
- this.apiKey = apiKey;
148
279
  this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
149
280
  }
150
281
  debug;
@@ -152,32 +283,28 @@ var ApiClient = class {
152
283
  if (this.debug) console.debug(`[ApiClient] ${msg}`);
153
284
  }
154
285
  headers() {
155
- const h = {
286
+ return {
156
287
  "Content-Type": "application/json"
157
288
  };
158
- if (this.apiKey) {
159
- h["Authorization"] = `Bearer ${this.apiKey}`;
160
- }
161
- return h;
162
289
  }
163
- async get(path3) {
164
- this.log(`GET ${path3}`);
165
- const res = await fetch(`${this.baseUrl}${path3}`, {
290
+ async get(path5) {
291
+ this.log(`GET ${path5}`);
292
+ const res = await fetch(`${this.baseUrl}${path5}`, {
166
293
  method: "GET",
167
294
  headers: this.headers()
168
295
  });
169
- return this.handleResponse(res, path3);
296
+ return this.handleResponse(res, path5);
170
297
  }
171
- async post(path3, body) {
172
- this.log(`POST ${path3}`);
173
- const res = await fetch(`${this.baseUrl}${path3}`, {
298
+ async post(path5, body) {
299
+ this.log(`POST ${path5}`);
300
+ const res = await fetch(`${this.baseUrl}${path5}`, {
174
301
  method: "POST",
175
302
  headers: this.headers(),
176
303
  body: body !== void 0 ? JSON.stringify(body) : void 0
177
304
  });
178
- return this.handleResponse(res, path3);
305
+ return this.handleResponse(res, path5);
179
306
  }
180
- async handleResponse(res, path3) {
307
+ async handleResponse(res, path5) {
181
308
  if (!res.ok) {
182
309
  let message = `HTTP ${res.status}`;
183
310
  try {
@@ -185,15 +312,21 @@ var ApiClient = class {
185
312
  if (body.error) message = body.error;
186
313
  } catch {
187
314
  }
188
- this.log(`${res.status} ${message} (${path3})`);
315
+ this.log(`${res.status} ${message} (${path5})`);
189
316
  throw new HttpError(res.status, message);
190
317
  }
191
- this.log(`${res.status} OK (${path3})`);
318
+ this.log(`${res.status} OK (${path5})`);
192
319
  return await res.json();
193
320
  }
194
321
  };
195
322
 
196
323
  // src/retry.ts
324
+ var NonRetryableError = class extends Error {
325
+ constructor(message) {
326
+ super(message);
327
+ this.name = "NonRetryableError";
328
+ }
329
+ };
197
330
  var DEFAULT_RETRY = {
198
331
  maxAttempts: 3,
199
332
  baseDelayMs: 1e3,
@@ -207,9 +340,11 @@ async function withRetry(fn, options = {}, signal) {
207
340
  try {
208
341
  return await fn();
209
342
  } catch (err) {
343
+ if (err instanceof NonRetryableError) throw err;
210
344
  lastError = err;
211
345
  if (attempt < opts.maxAttempts - 1) {
212
- const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
346
+ const baseDelay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
347
+ const delay = Math.round(baseDelay * (0.7 + Math.random() * 0.6));
213
348
  await sleep(delay, signal);
214
349
  }
215
350
  }
@@ -235,9 +370,9 @@ function sleep(ms, signal) {
235
370
  }
236
371
 
237
372
  // src/tool-executor.ts
238
- import { spawn, execFileSync } from "child_process";
239
- import * as fs2 from "fs";
240
- import * as path2 from "path";
373
+ import { spawn, execFileSync as execFileSync2 } from "child_process";
374
+ import * as fs3 from "fs";
375
+ import * as path3 from "path";
241
376
  var ToolTimeoutError = class extends Error {
242
377
  constructor(message) {
243
378
  super(message);
@@ -248,9 +383,9 @@ var MIN_PARTIAL_RESULT_LENGTH = 50;
248
383
  var MAX_STDERR_LENGTH = 1e3;
249
384
  function validateCommandBinary(commandTemplate) {
250
385
  const { command } = parseCommandTemplate(commandTemplate);
251
- if (path2.isAbsolute(command)) {
386
+ if (path3.isAbsolute(command)) {
252
387
  try {
253
- fs2.accessSync(command, fs2.constants.X_OK);
388
+ fs3.accessSync(command, fs3.constants.X_OK);
254
389
  return true;
255
390
  } catch {
256
391
  return false;
@@ -259,9 +394,9 @@ function validateCommandBinary(commandTemplate) {
259
394
  try {
260
395
  const isWindows = process.platform === "win32";
261
396
  if (isWindows) {
262
- execFileSync("where", [command], { stdio: "pipe" });
397
+ execFileSync2("where", [command], { stdio: "pipe" });
263
398
  } else {
264
- execFileSync("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
399
+ execFileSync2("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
265
400
  }
266
401
  return true;
267
402
  } catch {
@@ -324,9 +459,12 @@ function parseTokenUsage(stdout, stderr) {
324
459
  if (qwenMatch) return { tokens: parseInt(qwenMatch[1], 10), parsed: true };
325
460
  return { tokens: estimateTokens(stdout), parsed: false };
326
461
  }
327
- function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
462
+ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
328
463
  const promptViaArg = commandTemplate.includes("${PROMPT}");
329
464
  const allVars = { ...vars, PROMPT: prompt };
465
+ if (cwd && !allVars["CODEBASE_DIR"]) {
466
+ allVars["CODEBASE_DIR"] = cwd;
467
+ }
330
468
  const { command, args } = parseCommandTemplate(commandTemplate, allVars);
331
469
  return new Promise((resolve2, reject) => {
332
470
  if (signal?.aborted) {
@@ -334,7 +472,8 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
334
472
  return;
335
473
  }
336
474
  const child = spawn(command, args, {
337
- stdio: ["pipe", "pipe", "pipe"]
475
+ stdio: ["pipe", "pipe", "pipe"],
476
+ cwd
338
477
  });
339
478
  let stdout = "";
340
479
  let stderr = "";
@@ -412,6 +551,26 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
412
551
  });
413
552
  });
414
553
  }
554
+ var TEST_COMMAND_PROMPT = "Respond with: OK";
555
+ var TEST_COMMAND_TIMEOUT_MS = 1e4;
556
+ async function testCommand(commandTemplate) {
557
+ const start = Date.now();
558
+ try {
559
+ await executeTool(commandTemplate, TEST_COMMAND_PROMPT, TEST_COMMAND_TIMEOUT_MS);
560
+ return { ok: true, elapsedMs: Date.now() - start };
561
+ } catch (err) {
562
+ const elapsed = Date.now() - start;
563
+ if (err instanceof ToolTimeoutError) {
564
+ return {
565
+ ok: false,
566
+ elapsedMs: elapsed,
567
+ error: `command timed out after ${TEST_COMMAND_TIMEOUT_MS / 1e3}s`
568
+ };
569
+ }
570
+ const msg = err instanceof Error ? err.message : String(err);
571
+ return { ok: false, elapsedMs: elapsed, error: msg };
572
+ }
573
+ }
415
574
 
416
575
  // src/review.ts
417
576
  var TIMEOUT_SAFETY_MARGIN_MS = 3e4;
@@ -505,7 +664,9 @@ ${userMessage}`;
505
664
  deps.commandTemplate,
506
665
  fullPrompt,
507
666
  effectiveTimeout,
508
- abortController.signal
667
+ abortController.signal,
668
+ void 0,
669
+ deps.codebaseDir ?? void 0
509
670
  );
510
671
  const { verdict, review } = extractVerdict(result.stdout);
511
672
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
@@ -622,7 +783,9 @@ ${userMessage}`;
622
783
  deps.commandTemplate,
623
784
  fullPrompt,
624
785
  effectiveTimeout,
625
- abortController.signal
786
+ abortController.signal,
787
+ void 0,
788
+ deps.codebaseDir ?? void 0
626
789
  );
627
790
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
628
791
  return {
@@ -802,13 +965,45 @@ function formatPostReviewStats(session) {
802
965
  var DEFAULT_POLL_INTERVAL_MS = 1e4;
803
966
  var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
804
967
  var MAX_POLL_BACKOFF_MS = 3e5;
805
- async function fetchDiff(diffUrl, signal) {
806
- const patchUrl = diffUrl.endsWith(".diff") ? diffUrl : `${diffUrl}.diff`;
968
+ function createLogger(label) {
969
+ const prefix = label ? `[${label}] ` : "";
970
+ return {
971
+ log: (msg) => console.log(`${prefix}${sanitizeTokens(msg)}`),
972
+ logError: (msg) => console.error(`${prefix}${sanitizeTokens(msg)}`),
973
+ logWarn: (msg) => console.warn(`${prefix}${sanitizeTokens(msg)}`)
974
+ };
975
+ }
976
+ var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
977
+ function toApiDiffUrl(webUrl) {
978
+ const match = webUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.diff)?$/);
979
+ if (!match) return null;
980
+ const [, owner, repo, prNumber] = match;
981
+ return `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`;
982
+ }
983
+ async function fetchDiff(diffUrl, githubToken, signal) {
807
984
  return withRetry(
808
985
  async () => {
809
- const response = await fetch(patchUrl);
986
+ const headers = {};
987
+ let url;
988
+ const apiUrl = githubToken ? toApiDiffUrl(diffUrl) : null;
989
+ if (apiUrl && githubToken) {
990
+ url = apiUrl;
991
+ headers["Authorization"] = `Bearer ${githubToken}`;
992
+ headers["Accept"] = "application/vnd.github.v3.diff";
993
+ } else {
994
+ url = diffUrl.endsWith(".diff") ? diffUrl : `${diffUrl}.diff`;
995
+ if (githubToken) {
996
+ headers["Authorization"] = `Bearer ${githubToken}`;
997
+ }
998
+ }
999
+ const response = await fetch(url, { headers, signal });
810
1000
  if (!response.ok) {
811
- throw new Error(`Failed to fetch diff: ${response.status} ${response.statusText}`);
1001
+ const msg = `Failed to fetch diff: ${response.status} ${response.statusText}`;
1002
+ if (NON_RETRYABLE_STATUSES.has(response.status)) {
1003
+ const hint = response.status === 404 ? ". If this is a private repo, configure github_token in ~/.opencara/config.yml" : "";
1004
+ throw new NonRetryableError(`${msg}${hint}`);
1005
+ }
1006
+ throw new Error(msg);
812
1007
  }
813
1008
  return response.text();
814
1009
  },
@@ -816,11 +1011,14 @@ async function fetchDiff(diffUrl, signal) {
816
1011
  signal
817
1012
  );
818
1013
  }
819
- async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, options) {
820
- const { pollIntervalMs, routerRelay, reviewOnly, signal } = options;
821
- console.log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
1014
+ var MAX_DIFF_FETCH_ATTEMPTS = 3;
1015
+ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, options) {
1016
+ const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
1017
+ const { log, logError, logWarn } = logger;
1018
+ log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
822
1019
  let consecutiveAuthErrors = 0;
823
1020
  let consecutiveErrors = 0;
1021
+ const diffFailCounts = /* @__PURE__ */ new Map();
824
1022
  while (!signal?.aborted) {
825
1023
  try {
826
1024
  const pollBody = { agent_id: agentId };
@@ -828,35 +1026,53 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
828
1026
  const pollResponse = await client.post("/api/tasks/poll", pollBody);
829
1027
  consecutiveAuthErrors = 0;
830
1028
  consecutiveErrors = 0;
831
- if (pollResponse.tasks.length > 0) {
832
- const task = pollResponse.tasks[0];
833
- await handleTask(
1029
+ const eligibleTasks = repoConfig ? pollResponse.tasks.filter((t) => isRepoAllowed(repoConfig, t.owner, t.repo)) : pollResponse.tasks;
1030
+ const task = eligibleTasks.find(
1031
+ (t) => (diffFailCounts.get(t.task_id) ?? 0) < MAX_DIFF_FETCH_ATTEMPTS
1032
+ );
1033
+ if (task) {
1034
+ const result = await handleTask(
834
1035
  client,
835
1036
  agentId,
836
1037
  task,
837
1038
  reviewDeps,
838
1039
  consumptionDeps,
839
1040
  agentInfo,
1041
+ logger,
840
1042
  routerRelay,
841
1043
  signal
842
1044
  );
1045
+ if (result.diffFetchFailed) {
1046
+ const count = (diffFailCounts.get(task.task_id) ?? 0) + 1;
1047
+ diffFailCounts.set(task.task_id, count);
1048
+ if (count >= MAX_DIFF_FETCH_ATTEMPTS) {
1049
+ logWarn(` Skipping task ${task.task_id} after ${count} diff fetch failures`);
1050
+ }
1051
+ }
843
1052
  }
844
1053
  } catch (err) {
845
1054
  if (signal?.aborted) break;
846
1055
  if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
847
1056
  consecutiveAuthErrors++;
848
1057
  consecutiveErrors++;
849
- console.error(
1058
+ logError(
850
1059
  `Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
851
1060
  );
852
1061
  if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
853
- console.error("Authentication failed repeatedly. Exiting.");
1062
+ logError("Authentication failed repeatedly. Exiting.");
854
1063
  break;
855
1064
  }
856
1065
  } else {
857
1066
  consecutiveAuthErrors = 0;
858
1067
  consecutiveErrors++;
859
- console.error(`Poll error: ${err.message}`);
1068
+ logError(`Poll error: ${err.message}`);
1069
+ }
1070
+ if (consecutiveErrors >= maxConsecutiveErrors) {
1071
+ logError(
1072
+ `Too many consecutive errors (${consecutiveErrors}/${maxConsecutiveErrors}). Shutting down.`
1073
+ );
1074
+ process.exitCode = 1;
1075
+ break;
860
1076
  }
861
1077
  if (consecutiveErrors > 0) {
862
1078
  const backoff = Math.min(
@@ -865,7 +1081,7 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
865
1081
  );
866
1082
  const extraDelay = backoff - pollIntervalMs;
867
1083
  if (extraDelay > 0) {
868
- console.warn(
1084
+ logWarn(
869
1085
  `Poll failed (${consecutiveErrors} consecutive). Next poll in ${Math.round(backoff / 1e3)}s`
870
1086
  );
871
1087
  await sleep2(extraDelay, signal);
@@ -875,10 +1091,12 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
875
1091
  await sleep2(pollIntervalMs, signal);
876
1092
  }
877
1093
  }
878
- async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, routerRelay, signal) {
1094
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
879
1095
  const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
880
- console.log(`
1096
+ const { log, logError, logWarn } = logger;
1097
+ log(`
881
1098
  Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1099
+ log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
882
1100
  let claimResponse;
883
1101
  try {
884
1102
  claimResponse = await withRetry(
@@ -893,22 +1111,65 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
893
1111
  );
894
1112
  } catch (err) {
895
1113
  const status = err instanceof HttpError ? ` (${err.status})` : "";
896
- console.error(` Failed to claim task ${task_id}${status}: ${err.message}`);
897
- return;
1114
+ logError(` Failed to claim task ${task_id}${status}: ${err.message}`);
1115
+ return {};
898
1116
  }
899
1117
  if (!claimResponse.claimed) {
900
- console.log(` Claim rejected: ${claimResponse.reason}`);
901
- return;
1118
+ log(` Claim rejected: ${claimResponse.reason}`);
1119
+ return {};
902
1120
  }
903
- console.log(` Claimed as ${role}`);
1121
+ log(` Claimed as ${role}`);
904
1122
  let diffContent;
905
1123
  try {
906
- diffContent = await fetchDiff(diff_url, signal);
907
- console.log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
1124
+ diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
1125
+ log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
908
1126
  } catch (err) {
909
- console.error(` Failed to fetch diff for task ${task_id}: ${err.message}`);
910
- await safeReject(client, task_id, agentId, `Cannot access diff: ${err.message}`);
911
- return;
1127
+ logError(` Failed to fetch diff for task ${task_id}: ${err.message}`);
1128
+ await safeReject(
1129
+ client,
1130
+ task_id,
1131
+ agentId,
1132
+ `Cannot access diff: ${err.message}`,
1133
+ logger
1134
+ );
1135
+ return { diffFetchFailed: true };
1136
+ }
1137
+ let taskReviewDeps = reviewDeps;
1138
+ let taskCheckoutPath = null;
1139
+ if (reviewDeps.codebaseDir) {
1140
+ try {
1141
+ const result = cloneOrUpdate(
1142
+ owner,
1143
+ repo,
1144
+ pr_number,
1145
+ reviewDeps.codebaseDir,
1146
+ reviewDeps.githubToken,
1147
+ task_id
1148
+ );
1149
+ log(` Codebase ${result.cloned ? "cloned" : "updated"}: ${result.localPath}`);
1150
+ taskCheckoutPath = result.localPath;
1151
+ taskReviewDeps = { ...reviewDeps, codebaseDir: result.localPath };
1152
+ } catch (err) {
1153
+ logWarn(
1154
+ ` Warning: codebase clone failed: ${err.message}. Continuing with diff-only review.`
1155
+ );
1156
+ taskReviewDeps = { ...reviewDeps, codebaseDir: null };
1157
+ }
1158
+ } else {
1159
+ try {
1160
+ validatePathSegment(owner, "owner");
1161
+ validatePathSegment(repo, "repo");
1162
+ validatePathSegment(task_id, "task_id");
1163
+ const repoScopedDir = path4.join(CONFIG_DIR, "repos", owner, repo, task_id);
1164
+ fs4.mkdirSync(repoScopedDir, { recursive: true });
1165
+ taskCheckoutPath = repoScopedDir;
1166
+ taskReviewDeps = { ...reviewDeps, codebaseDir: repoScopedDir };
1167
+ log(` Working directory: ${repoScopedDir}`);
1168
+ } catch (err) {
1169
+ logWarn(
1170
+ ` Warning: failed to create working directory: ${err.message}. Continuing without scoped cwd.`
1171
+ );
1172
+ }
912
1173
  }
913
1174
  try {
914
1175
  if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
@@ -923,8 +1184,9 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
923
1184
  prompt,
924
1185
  timeout_seconds,
925
1186
  claimResponse.reviews,
926
- reviewDeps,
1187
+ taskReviewDeps,
927
1188
  consumptionDeps,
1189
+ logger,
928
1190
  routerRelay,
929
1191
  signal
930
1192
  );
@@ -939,50 +1201,64 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
939
1201
  diffContent,
940
1202
  prompt,
941
1203
  timeout_seconds,
942
- reviewDeps,
1204
+ taskReviewDeps,
943
1205
  consumptionDeps,
1206
+ logger,
944
1207
  routerRelay,
945
1208
  signal
946
1209
  );
947
1210
  }
948
1211
  } catch (err) {
949
1212
  if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
950
- console.error(` ${err.message}`);
951
- await safeReject(client, task_id, agentId, err.message);
1213
+ logError(` ${err.message}`);
1214
+ await safeReject(client, task_id, agentId, err.message, logger);
952
1215
  } else {
953
- console.error(` Error on task ${task_id}: ${err.message}`);
954
- await safeError(client, task_id, agentId, err.message);
1216
+ logError(` Error on task ${task_id}: ${err.message}`);
1217
+ await safeError(client, task_id, agentId, err.message, logger);
1218
+ }
1219
+ } finally {
1220
+ if (taskCheckoutPath) {
1221
+ cleanupTaskDir(taskCheckoutPath);
955
1222
  }
956
1223
  }
1224
+ return {};
957
1225
  }
958
- async function safeReject(client, taskId, agentId, reason) {
1226
+ async function safeReject(client, taskId, agentId, reason, logger) {
959
1227
  try {
960
1228
  await withRetry(
961
- () => client.post(`/api/tasks/${taskId}/reject`, { agent_id: agentId, reason }),
1229
+ () => client.post(`/api/tasks/${taskId}/reject`, {
1230
+ agent_id: agentId,
1231
+ reason: sanitizeTokens(reason)
1232
+ }),
962
1233
  { maxAttempts: 2 }
963
1234
  );
964
1235
  } catch (err) {
965
- console.error(
1236
+ logger.logError(
966
1237
  ` Failed to report rejection for task ${taskId}: ${err.message} (logged locally)`
967
1238
  );
968
1239
  }
969
1240
  }
970
- async function safeError(client, taskId, agentId, error) {
1241
+ async function safeError(client, taskId, agentId, error, logger) {
971
1242
  try {
972
- await withRetry(() => client.post(`/api/tasks/${taskId}/error`, { agent_id: agentId, error }), {
973
- maxAttempts: 2
974
- });
1243
+ await withRetry(
1244
+ () => client.post(`/api/tasks/${taskId}/error`, {
1245
+ agent_id: agentId,
1246
+ error: sanitizeTokens(error)
1247
+ }),
1248
+ { maxAttempts: 2 }
1249
+ );
975
1250
  } catch (err) {
976
- console.error(
1251
+ logger.logError(
977
1252
  ` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
978
1253
  );
979
1254
  }
980
1255
  }
981
- async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, routerRelay, signal) {
1256
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
982
1257
  let reviewText;
983
1258
  let verdict;
984
1259
  let tokensUsed;
985
1260
  if (routerRelay) {
1261
+ logger.log(` Executing review command: [router mode]`);
986
1262
  const fullPrompt = routerRelay.buildReviewPrompt({
987
1263
  owner,
988
1264
  repo,
@@ -1001,6 +1277,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1001
1277
  verdict = parsed.verdict;
1002
1278
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1003
1279
  } else {
1280
+ logger.log(` Executing review command: ${reviewDeps.commandTemplate}`);
1004
1281
  const result = await executeReview(
1005
1282
  {
1006
1283
  taskId,
@@ -1018,11 +1295,12 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1018
1295
  verdict = result.verdict;
1019
1296
  tokensUsed = result.tokensUsed;
1020
1297
  }
1298
+ const sanitizedReview = sanitizeTokens(reviewText);
1021
1299
  await withRetry(
1022
1300
  () => client.post(`/api/tasks/${taskId}/result`, {
1023
1301
  agent_id: agentId,
1024
1302
  type: "review",
1025
- review_text: reviewText,
1303
+ review_text: sanitizedReview,
1026
1304
  verdict,
1027
1305
  tokens_used: tokensUsed
1028
1306
  }),
@@ -1030,26 +1308,68 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1030
1308
  signal
1031
1309
  );
1032
1310
  recordSessionUsage(consumptionDeps.session, tokensUsed);
1033
- console.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1034
- console.log(formatPostReviewStats(consumptionDeps.session));
1311
+ logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1312
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1035
1313
  }
1036
- async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, routerRelay, signal) {
1314
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
1037
1315
  if (reviews.length === 0) {
1038
- return executeReviewTask(
1039
- client,
1040
- agentId,
1041
- taskId,
1042
- owner,
1043
- repo,
1044
- prNumber,
1045
- diffContent,
1046
- prompt,
1047
- timeoutSeconds,
1048
- reviewDeps,
1049
- consumptionDeps,
1050
- routerRelay,
1316
+ let reviewText;
1317
+ let verdict;
1318
+ let tokensUsed2;
1319
+ if (routerRelay) {
1320
+ logger.log(` Executing summary command: [router mode]`);
1321
+ const fullPrompt = routerRelay.buildReviewPrompt({
1322
+ owner,
1323
+ repo,
1324
+ reviewMode: "full",
1325
+ prompt,
1326
+ diffContent
1327
+ });
1328
+ const response = await routerRelay.sendPrompt(
1329
+ "review_request",
1330
+ taskId,
1331
+ fullPrompt,
1332
+ timeoutSeconds
1333
+ );
1334
+ const parsed = routerRelay.parseReviewResponse(response);
1335
+ reviewText = parsed.review;
1336
+ verdict = parsed.verdict;
1337
+ tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
1338
+ } else {
1339
+ logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
1340
+ const result = await executeReview(
1341
+ {
1342
+ taskId,
1343
+ diffContent,
1344
+ prompt,
1345
+ owner,
1346
+ repo,
1347
+ prNumber,
1348
+ timeout: timeoutSeconds,
1349
+ reviewMode: "full"
1350
+ },
1351
+ reviewDeps
1352
+ );
1353
+ reviewText = result.review;
1354
+ verdict = result.verdict;
1355
+ tokensUsed2 = result.tokensUsed;
1356
+ }
1357
+ const sanitizedReview = sanitizeTokens(reviewText);
1358
+ await withRetry(
1359
+ () => client.post(`/api/tasks/${taskId}/result`, {
1360
+ agent_id: agentId,
1361
+ type: "summary",
1362
+ review_text: sanitizedReview,
1363
+ verdict,
1364
+ tokens_used: tokensUsed2
1365
+ }),
1366
+ { maxAttempts: 3 },
1051
1367
  signal
1052
1368
  );
1369
+ recordSessionUsage(consumptionDeps.session, tokensUsed2);
1370
+ logger.log(` Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`);
1371
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1372
+ return;
1053
1373
  }
1054
1374
  const summaryReviews = reviews.map((r) => ({
1055
1375
  agentId: r.agent_id,
@@ -1061,6 +1381,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1061
1381
  let summaryText;
1062
1382
  let tokensUsed;
1063
1383
  if (routerRelay) {
1384
+ logger.log(` Executing summary command: [router mode]`);
1064
1385
  const fullPrompt = routerRelay.buildSummaryPrompt({
1065
1386
  owner,
1066
1387
  repo,
@@ -1077,6 +1398,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1077
1398
  summaryText = response;
1078
1399
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1079
1400
  } else {
1401
+ logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
1080
1402
  const result = await executeSummary(
1081
1403
  {
1082
1404
  taskId,
@@ -1093,19 +1415,20 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1093
1415
  summaryText = result.summary;
1094
1416
  tokensUsed = result.tokensUsed;
1095
1417
  }
1418
+ const sanitizedSummary = sanitizeTokens(summaryText);
1096
1419
  await withRetry(
1097
1420
  () => client.post(`/api/tasks/${taskId}/result`, {
1098
1421
  agent_id: agentId,
1099
1422
  type: "summary",
1100
- review_text: summaryText,
1423
+ review_text: sanitizedSummary,
1101
1424
  tokens_used: tokensUsed
1102
1425
  }),
1103
1426
  { maxAttempts: 3 },
1104
1427
  signal
1105
1428
  );
1106
1429
  recordSessionUsage(consumptionDeps.session, tokensUsed);
1107
- console.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1108
- console.log(formatPostReviewStats(consumptionDeps.session));
1430
+ logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1431
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1109
1432
  }
1110
1433
  function sleep2(ms, signal) {
1111
1434
  return new Promise((resolve2) => {
@@ -1127,29 +1450,42 @@ function sleep2(ms, signal) {
1127
1450
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
1128
1451
  const client = new ApiClient(platformUrl);
1129
1452
  const session = consumptionDeps?.session ?? createSessionTracker();
1130
- const deps = consumptionDeps ?? { agentId, limits: null, session };
1131
- console.log(`Agent ${agentId} starting...`);
1132
- console.log(`Platform: ${platformUrl}`);
1133
- console.log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
1453
+ const deps = consumptionDeps ?? { agentId, session };
1454
+ const logger = createLogger(options?.label);
1455
+ const { log, logError, logWarn } = logger;
1456
+ log(`Agent ${agentId} starting...`);
1457
+ log(`Platform: ${platformUrl}`);
1458
+ log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
1134
1459
  if (!reviewDeps) {
1135
- console.error("No review command configured. Set command in config.yml");
1460
+ logError("No review command configured. Set command in config.yml");
1136
1461
  return;
1137
1462
  }
1463
+ if (reviewDeps.commandTemplate && !options?.routerRelay) {
1464
+ log("Testing command...");
1465
+ const result = await testCommand(reviewDeps.commandTemplate);
1466
+ if (result.ok) {
1467
+ log(`Testing command... ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
1468
+ } else {
1469
+ logWarn(`Warning: command test failed (${result.error}). Reviews may fail.`);
1470
+ }
1471
+ }
1138
1472
  const abortController = new AbortController();
1139
1473
  process.on("SIGINT", () => {
1140
- console.log("\nShutting down...");
1474
+ log("\nShutting down...");
1141
1475
  abortController.abort();
1142
1476
  });
1143
1477
  process.on("SIGTERM", () => {
1144
1478
  abortController.abort();
1145
1479
  });
1146
- await pollLoop(client, agentId, reviewDeps, deps, agentInfo, {
1480
+ await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
1147
1481
  pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
1482
+ maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
1148
1483
  routerRelay: options?.routerRelay,
1149
1484
  reviewOnly: options?.reviewOnly,
1485
+ repoConfig: options?.repoConfig,
1150
1486
  signal: abortController.signal
1151
1487
  });
1152
- console.log("Agent stopped.");
1488
+ log("Agent stopped.");
1153
1489
  }
1154
1490
  async function startAgentRouter() {
1155
1491
  const config = loadConfig();
@@ -1164,14 +1500,21 @@ async function startAgentRouter() {
1164
1500
  }
1165
1501
  const router = new RouterRelay();
1166
1502
  router.start();
1503
+ const configToken = resolveGithubToken(agentConfig?.github_token, config.githubToken);
1504
+ const auth = resolveGithubToken2(configToken);
1505
+ const logger = createLogger(agentConfig?.name ?? "agent[0]");
1506
+ logAuthMethod(auth.method, logger.log);
1507
+ const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
1167
1508
  const reviewDeps = {
1168
1509
  commandTemplate: commandTemplate ?? "",
1169
- maxDiffSizeKb: config.maxDiffSizeKb
1510
+ maxDiffSizeKb: config.maxDiffSizeKb,
1511
+ githubToken: auth.token,
1512
+ codebaseDir
1170
1513
  };
1171
1514
  const session = createSessionTracker();
1172
- const limits = agentConfig ? resolveAgentLimits(agentConfig.limits, config.limits) : config.limits;
1173
1515
  const model = agentConfig?.model ?? "unknown";
1174
1516
  const tool = agentConfig?.tool ?? "unknown";
1517
+ const label = agentConfig?.name ?? "agent[0]";
1175
1518
  await startAgent(
1176
1519
  agentId,
1177
1520
  config.platformUrl,
@@ -1179,47 +1522,52 @@ async function startAgentRouter() {
1179
1522
  reviewDeps,
1180
1523
  {
1181
1524
  agentId,
1182
- limits,
1183
1525
  session
1184
1526
  },
1185
1527
  {
1528
+ maxConsecutiveErrors: config.maxConsecutiveErrors,
1186
1529
  routerRelay: router,
1187
- reviewOnly: agentConfig?.review_only
1530
+ reviewOnly: agentConfig?.review_only,
1531
+ repoConfig: agentConfig?.repos,
1532
+ label
1188
1533
  }
1189
1534
  );
1190
1535
  router.stop();
1191
1536
  }
1192
- var agentCommand = new Command("agent").description("Manage review agents");
1193
- agentCommand.command("start").description("Start an agent in polling mode").option("--poll-interval <seconds>", "Poll interval in seconds", "10").option("--agent <index>", "Agent index from config.yml (0-based)", "0").action(async (opts) => {
1194
- const config = loadConfig();
1195
- const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
1196
- const agentIndex = parseInt(opts.agent, 10);
1537
+ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
1197
1538
  const agentId = crypto.randomUUID();
1198
1539
  let commandTemplate;
1199
- let limits = config.limits;
1200
1540
  let agentConfig;
1201
1541
  if (config.agents && config.agents.length > agentIndex) {
1202
1542
  agentConfig = config.agents[agentIndex];
1203
1543
  commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
1204
- limits = resolveAgentLimits(agentConfig.limits, config.limits);
1205
1544
  } else {
1206
1545
  commandTemplate = config.agentCommand ?? void 0;
1207
1546
  }
1547
+ const label = agentConfig?.name ?? `agent[${agentIndex}]`;
1208
1548
  if (!commandTemplate) {
1549
+ console.error(`[${label}] No command configured. Skipping.`);
1550
+ return null;
1551
+ }
1552
+ if (!validateCommandBinary(commandTemplate)) {
1209
1553
  console.error(
1210
- "No command configured. Set agent_command or agents[].command in ~/.opencara/config.yml"
1554
+ `[${label}] Command binary not found: ${commandTemplate.split(" ")[0]}. Skipping.`
1211
1555
  );
1212
- process.exit(1);
1213
- return;
1556
+ return null;
1214
1557
  }
1215
- if (!validateCommandBinary(commandTemplate)) {
1216
- console.error(`Command binary not found: ${commandTemplate.split(" ")[0]}`);
1217
- process.exit(1);
1218
- return;
1558
+ let githubToken;
1559
+ if (auth.method === "env" || auth.method === "gh-cli") {
1560
+ githubToken = auth.token;
1561
+ } else {
1562
+ const configToken = agentConfig ? resolveGithubToken(agentConfig.github_token, config.githubToken) : config.githubToken;
1563
+ githubToken = configToken;
1219
1564
  }
1565
+ const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
1220
1566
  const reviewDeps = {
1221
1567
  commandTemplate,
1222
- maxDiffSizeKb: config.maxDiffSizeKb
1568
+ maxDiffSizeKb: config.maxDiffSizeKb,
1569
+ githubToken,
1570
+ codebaseDir
1223
1571
  };
1224
1572
  const isRouter = agentConfig?.router === true;
1225
1573
  let routerRelay;
@@ -1230,30 +1578,90 @@ agentCommand.command("start").description("Start an agent in polling mode").opti
1230
1578
  const session = createSessionTracker();
1231
1579
  const model = agentConfig?.model ?? "unknown";
1232
1580
  const tool = agentConfig?.tool ?? "unknown";
1233
- try {
1234
- await startAgent(
1235
- agentId,
1236
- config.platformUrl,
1237
- { model, tool },
1238
- reviewDeps,
1239
- {
1240
- agentId,
1241
- limits,
1242
- session
1243
- },
1244
- {
1245
- pollIntervalMs,
1246
- routerRelay,
1247
- reviewOnly: agentConfig?.review_only
1248
- }
1249
- );
1250
- } finally {
1581
+ const agentPromise = startAgent(
1582
+ agentId,
1583
+ config.platformUrl,
1584
+ { model, tool },
1585
+ reviewDeps,
1586
+ { agentId, session },
1587
+ {
1588
+ pollIntervalMs,
1589
+ maxConsecutiveErrors: config.maxConsecutiveErrors,
1590
+ routerRelay,
1591
+ reviewOnly: agentConfig?.review_only,
1592
+ repoConfig: agentConfig?.repos,
1593
+ label
1594
+ }
1595
+ ).finally(() => {
1251
1596
  routerRelay?.stop();
1597
+ });
1598
+ return agentPromise;
1599
+ }
1600
+ var agentCommand = new Command("agent").description("Manage review agents");
1601
+ agentCommand.command("start").description("Start agents in polling mode").option("--poll-interval <seconds>", "Poll interval in seconds", "10").option("--agent <index>", "Agent index from config.yml (0-based)", "0").option("--all", "Start all configured agents concurrently").action(async (opts) => {
1602
+ const config = loadConfig();
1603
+ const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
1604
+ const configToken = resolveGithubToken(void 0, config.githubToken);
1605
+ const auth = resolveGithubToken2(configToken);
1606
+ logAuthMethod(auth.method, console.log.bind(console));
1607
+ if (opts.all) {
1608
+ if (!config.agents || config.agents.length === 0) {
1609
+ console.error("No agents configured in ~/.opencara/config.yml");
1610
+ process.exit(1);
1611
+ return;
1612
+ }
1613
+ console.log(`Starting ${config.agents.length} agent(s)...`);
1614
+ const promises = [];
1615
+ let startFailed = false;
1616
+ for (let i = 0; i < config.agents.length; i++) {
1617
+ const p = startAgentByIndex(config, i, pollIntervalMs, auth);
1618
+ if (p) {
1619
+ promises.push(p);
1620
+ } else {
1621
+ startFailed = true;
1622
+ }
1623
+ }
1624
+ if (promises.length === 0) {
1625
+ console.error("No agents could be started. Check your config.");
1626
+ process.exit(1);
1627
+ return;
1628
+ }
1629
+ if (startFailed) {
1630
+ console.error(
1631
+ "One or more agents could not start (see warnings above). Continuing with the rest."
1632
+ );
1633
+ }
1634
+ console.log(`${promises.length} agent(s) running. Press Ctrl+C to stop all.
1635
+ `);
1636
+ const results = await Promise.allSettled(promises);
1637
+ const failures = results.filter((r) => r.status === "rejected");
1638
+ if (failures.length > 0) {
1639
+ for (const f of failures) {
1640
+ console.error(`Agent exited with error: ${f.reason}`);
1641
+ }
1642
+ process.exit(1);
1643
+ }
1644
+ } else {
1645
+ const maxIndex = (config.agents?.length ?? 0) - 1;
1646
+ const agentIndex = Number(opts.agent);
1647
+ if (!Number.isInteger(agentIndex) || agentIndex < 0 || agentIndex > maxIndex) {
1648
+ console.error(
1649
+ maxIndex >= 0 ? `--agent must be an integer between 0 and ${maxIndex}.` : "No agents configured in ~/.opencara/config.yml"
1650
+ );
1651
+ process.exit(1);
1652
+ return;
1653
+ }
1654
+ const p = startAgentByIndex(config, agentIndex, pollIntervalMs, auth);
1655
+ if (!p) {
1656
+ process.exit(1);
1657
+ return;
1658
+ }
1659
+ await p;
1252
1660
  }
1253
1661
  });
1254
1662
 
1255
1663
  // src/index.ts
1256
- var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.10.0");
1664
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.11.0");
1257
1665
  program.addCommand(agentCommand);
1258
1666
  program.action(() => {
1259
1667
  startAgentRouter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",