opencara 0.9.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 +905 -1663
  2. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -1,11 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command4 } from "commander";
4
+ import { Command as Command2 } from "commander";
5
5
 
6
- // src/commands/login.ts
6
+ // src/commands/agent.ts
7
7
  import { Command } from "commander";
8
- import * as readline from "readline";
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";
@@ -15,22 +39,8 @@ import { parse, stringify } from "yaml";
15
39
  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
- function ensureConfigDir() {
19
- const dir = path.dirname(CONFIG_FILE);
20
- fs.mkdirSync(dir, { recursive: true });
21
- }
22
42
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
23
- function parseLimits(data) {
24
- const raw = data.limits;
25
- if (!raw || typeof raw !== "object") return null;
26
- const obj = raw;
27
- const limits = {};
28
- if (typeof obj.tokens_per_day === "number") limits.tokens_per_day = obj.tokens_per_day;
29
- if (typeof obj.tokens_per_month === "number") limits.tokens_per_month = obj.tokens_per_month;
30
- if (typeof obj.reviews_per_day === "number") limits.reviews_per_day = obj.reviews_per_day;
31
- if (Object.keys(limits).length === 0) return null;
32
- return limits;
33
- }
43
+ var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
34
44
  var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
35
45
  var REPO_PATTERN = /^[^/]+\/[^/]+$/;
36
46
  var RepoConfigError = class extends Error {
@@ -74,44 +84,6 @@ function parseRepoConfig(obj, index) {
74
84
  }
75
85
  return config;
76
86
  }
77
- function parseAnonymousAgents(data) {
78
- const raw = data.anonymous_agents;
79
- if (!Array.isArray(raw)) return [];
80
- const entries = [];
81
- for (let i = 0; i < raw.length; i++) {
82
- const entry = raw[i];
83
- if (!entry || typeof entry !== "object") {
84
- console.warn(`Warning: anonymous_agents[${i}] is not an object, skipping`);
85
- continue;
86
- }
87
- const obj = entry;
88
- if (typeof obj.agent_id !== "string" || typeof obj.api_key !== "string" || typeof obj.model !== "string" || typeof obj.tool !== "string") {
89
- console.warn(
90
- `Warning: anonymous_agents[${i}] missing required agent_id/api_key/model/tool fields, skipping`
91
- );
92
- continue;
93
- }
94
- const anon = {
95
- agentId: obj.agent_id,
96
- apiKey: obj.api_key,
97
- model: obj.model,
98
- tool: obj.tool
99
- };
100
- if (typeof obj.name === "string") anon.name = obj.name;
101
- if (obj.repo_config && typeof obj.repo_config === "object") {
102
- const rc = obj.repo_config;
103
- if (typeof rc.mode === "string" && VALID_REPO_MODES.includes(rc.mode)) {
104
- const repoConfig = { mode: rc.mode };
105
- if (Array.isArray(rc.list)) {
106
- repoConfig.list = rc.list.filter((v) => typeof v === "string");
107
- }
108
- anon.repoConfig = repoConfig;
109
- }
110
- }
111
- entries.push(anon);
112
- }
113
- return entries;
114
- }
115
87
  function parseAgents(data) {
116
88
  if (!("agents" in data)) return null;
117
89
  const raw = data.agents;
@@ -132,8 +104,9 @@ function parseAgents(data) {
132
104
  if (typeof obj.name === "string") agent.name = obj.name;
133
105
  if (typeof obj.command === "string") agent.command = obj.command;
134
106
  if (obj.router === true) agent.router = true;
135
- const agentLimits = parseLimits(obj);
136
- if (agentLimits) agent.limits = agentLimits;
107
+ if (obj.review_only === true) agent.review_only = true;
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;
137
110
  const repoConfig = parseRepoConfig(obj, i);
138
111
  if (repoConfig) agent.repos = repoConfig;
139
112
  agents.push(agent);
@@ -141,14 +114,15 @@ function parseAgents(data) {
141
114
  return agents;
142
115
  }
143
116
  function loadConfig() {
117
+ const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
144
118
  const defaults = {
145
- apiKey: null,
146
- platformUrl: DEFAULT_PLATFORM_URL,
119
+ platformUrl: envPlatformUrl || DEFAULT_PLATFORM_URL,
147
120
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
148
- limits: null,
121
+ maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
122
+ githubToken: null,
123
+ codebaseDir: null,
149
124
  agentCommand: null,
150
- agents: null,
151
- anonymousAgents: []
125
+ agents: null
152
126
  };
153
127
  if (!fs.existsSync(CONFIG_FILE)) {
154
128
  return defaults;
@@ -159,73 +133,136 @@ function loadConfig() {
159
133
  return defaults;
160
134
  }
161
135
  return {
162
- apiKey: typeof data.api_key === "string" ? data.api_key : null,
163
- 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),
164
137
  maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
165
- 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,
166
141
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
167
- agents: parseAgents(data),
168
- anonymousAgents: parseAnonymousAgents(data)
142
+ agents: parseAgents(data)
169
143
  };
170
144
  }
171
- function saveConfig(config) {
172
- ensureConfigDir();
173
- const data = {
174
- platform_url: config.platformUrl
175
- };
176
- if (config.apiKey) {
177
- data.api_key = config.apiKey;
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));
178
153
  }
179
- if (config.maxDiffSizeKb !== DEFAULT_MAX_DIFF_SIZE_KB) {
180
- data.max_diff_size_kb = config.maxDiffSizeKb;
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;
181
193
  }
182
- if (config.limits) {
183
- data.limits = config.limits;
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
+ }
184
200
  }
185
- if (config.agentCommand) {
186
- data.agent_command = config.agentCommand;
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`);
187
205
  }
188
- if (config.agents !== null) {
189
- data.agents = config.agents;
206
+ }
207
+ function buildCloneUrl(owner, repo, githubToken) {
208
+ if (githubToken) {
209
+ return `https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`;
190
210
  }
191
- if (config.anonymousAgents.length > 0) {
192
- data.anonymous_agents = config.anonymousAgents.map((a) => {
193
- const entry = {
194
- agent_id: a.agentId,
195
- api_key: a.apiKey,
196
- model: a.model,
197
- tool: a.tool
198
- };
199
- if (a.name) {
200
- entry.name = a.name;
201
- }
202
- if (a.repoConfig) {
203
- entry.repo_config = a.repoConfig;
204
- }
205
- return entry;
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"]
206
220
  });
221
+ } catch (err) {
222
+ const message = err instanceof Error ? err.message : String(err);
223
+ throw new Error(sanitizeTokens(message));
207
224
  }
208
- fs.writeFileSync(CONFIG_FILE, stringify(data), { encoding: "utf-8", mode: 384 });
209
- }
210
- function resolveAgentLimits(agentLimits, globalLimits) {
211
- if (!agentLimits && !globalLimits) return null;
212
- if (!agentLimits) return globalLimits;
213
- if (!globalLimits) return agentLimits;
214
- const merged = { ...globalLimits, ...agentLimits };
215
- return Object.keys(merged).length === 0 ? null : merged;
216
- }
217
- function findAnonymousAgent(config, agentId) {
218
- return config.anonymousAgents.find((a) => a.agentId === agentId) ?? null;
219
225
  }
220
- function removeAnonymousAgent(config, agentId) {
221
- config.anonymousAgents = config.anonymousAgents.filter((a) => a.agentId !== agentId);
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
+ }
222
241
  }
223
- function requireApiKey(config) {
224
- if (!config.apiKey) {
225
- console.error("Not authenticated. Run `opencara login` first.");
226
- process.exit(1);
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" };
227
252
  }
228
- return config.apiKey;
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]);
229
266
  }
230
267
 
231
268
  // src/http.ts
@@ -237,264 +274,105 @@ var HttpError = class extends Error {
237
274
  }
238
275
  };
239
276
  var ApiClient = class {
240
- constructor(baseUrl, apiKey = null) {
277
+ constructor(baseUrl, debug) {
241
278
  this.baseUrl = baseUrl;
242
- this.apiKey = apiKey;
279
+ this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
280
+ }
281
+ debug;
282
+ log(msg) {
283
+ if (this.debug) console.debug(`[ApiClient] ${msg}`);
243
284
  }
244
285
  headers() {
245
- const h = {
286
+ return {
246
287
  "Content-Type": "application/json"
247
288
  };
248
- if (this.apiKey) {
249
- h["Authorization"] = `Bearer ${this.apiKey}`;
250
- }
251
- return h;
252
289
  }
253
- async get(path3) {
254
- const res = await fetch(`${this.baseUrl}${path3}`, {
290
+ async get(path5) {
291
+ this.log(`GET ${path5}`);
292
+ const res = await fetch(`${this.baseUrl}${path5}`, {
255
293
  method: "GET",
256
294
  headers: this.headers()
257
295
  });
258
- return this.handleResponse(res);
296
+ return this.handleResponse(res, path5);
259
297
  }
260
- async post(path3, body) {
261
- 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}`, {
262
301
  method: "POST",
263
302
  headers: this.headers(),
264
303
  body: body !== void 0 ? JSON.stringify(body) : void 0
265
304
  });
266
- return this.handleResponse(res);
305
+ return this.handleResponse(res, path5);
267
306
  }
268
- async handleResponse(res) {
307
+ async handleResponse(res, path5) {
269
308
  if (!res.ok) {
270
- if (res.status === 401) {
271
- throw new HttpError(401, "Not authenticated. Run `opencara login` first.");
272
- }
273
309
  let message = `HTTP ${res.status}`;
274
310
  try {
275
311
  const body = await res.json();
276
312
  if (body.error) message = body.error;
277
313
  } catch {
278
314
  }
315
+ this.log(`${res.status} ${message} (${path5})`);
279
316
  throw new HttpError(res.status, message);
280
317
  }
318
+ this.log(`${res.status} OK (${path5})`);
281
319
  return await res.json();
282
320
  }
283
321
  };
284
322
 
285
- // src/reconnect.ts
286
- var DEFAULT_RECONNECT_OPTIONS = {
287
- initialDelay: 1e3,
288
- maxDelay: 3e4,
289
- multiplier: 2,
290
- jitter: true
291
- };
292
- function calculateDelay(attempt, options = DEFAULT_RECONNECT_OPTIONS) {
293
- const base = Math.min(
294
- options.initialDelay * Math.pow(options.multiplier, attempt),
295
- options.maxDelay
296
- );
297
- if (options.jitter) {
298
- return base + Math.random() * 500;
323
+ // src/retry.ts
324
+ var NonRetryableError = class extends Error {
325
+ constructor(message) {
326
+ super(message);
327
+ this.name = "NonRetryableError";
299
328
  }
300
- return base;
301
- }
302
- function sleep(ms) {
303
- return new Promise((resolve2) => setTimeout(resolve2, ms));
304
- }
305
-
306
- // src/commands/login.ts
307
- function promptYesNo(question) {
308
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
309
- return new Promise((resolve2) => {
310
- rl.question(question, (answer) => {
311
- rl.close();
312
- const normalized = answer.trim().toLowerCase();
313
- resolve2(normalized === "" || normalized === "y" || normalized === "yes");
314
- });
315
- });
316
- }
317
- var loginCommand = new Command("login").description("Authenticate with GitHub via device flow").action(async () => {
318
- const config = loadConfig();
319
- const client = new ApiClient(config.platformUrl);
320
- let flow;
321
- try {
322
- flow = await client.post("/auth/device");
323
- } catch (err) {
324
- console.error("Failed to start device flow:", err instanceof Error ? err.message : err);
325
- process.exit(1);
326
- }
327
- console.log();
328
- console.log("To sign in, open this URL in your browser:");
329
- console.log(` ${flow.verificationUri}`);
330
- console.log();
331
- console.log(`And enter this code: ${flow.userCode}`);
332
- console.log();
333
- console.log("Waiting for authorization...");
334
- const intervalMs = flow.interval * 1e3;
335
- const deadline = Date.now() + flow.expiresIn * 1e3;
336
- while (Date.now() < deadline) {
337
- await sleep(intervalMs);
338
- let tokenRes;
329
+ };
330
+ var DEFAULT_RETRY = {
331
+ maxAttempts: 3,
332
+ baseDelayMs: 1e3,
333
+ maxDelayMs: 3e4
334
+ };
335
+ async function withRetry(fn, options = {}, signal) {
336
+ const opts = { ...DEFAULT_RETRY, ...options };
337
+ let lastError;
338
+ for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
339
+ if (signal?.aborted) throw new Error("Aborted");
339
340
  try {
340
- tokenRes = await client.post("/auth/device/token", {
341
- deviceCode: flow.deviceCode
342
- });
341
+ return await fn();
343
342
  } catch (err) {
344
- console.error("Polling error:", err instanceof Error ? err.message : err);
345
- continue;
346
- }
347
- if (tokenRes.status === "pending") {
348
- process.stdout.write(".");
349
- continue;
350
- }
351
- if (tokenRes.status === "expired") {
352
- console.error("\nDevice code expired. Please run `opencara login` again.");
353
- process.exit(1);
354
- }
355
- if (tokenRes.status === "complete") {
356
- config.apiKey = tokenRes.apiKey;
357
- saveConfig(config);
358
- console.log("\nLogged in successfully. API key saved to ~/.opencara/config.yml");
359
- if (config.anonymousAgents.length > 0 && process.stdin.isTTY) {
360
- console.log();
361
- console.log(`Found ${config.anonymousAgents.length} anonymous agent(s):`);
362
- for (const anon of config.anonymousAgents) {
363
- console.log(` - ${anon.agentId} (${anon.model} / ${anon.tool})`);
364
- }
365
- const shouldLink = await promptYesNo("Link to your GitHub account? [Y/n] ");
366
- if (shouldLink) {
367
- const authedClient = new ApiClient(config.platformUrl, tokenRes.apiKey);
368
- let linkedCount = 0;
369
- const toRemove = [];
370
- for (const anon of config.anonymousAgents) {
371
- try {
372
- await authedClient.post("/api/account/link", {
373
- anonymousApiKey: anon.apiKey
374
- });
375
- toRemove.push(anon.agentId);
376
- linkedCount++;
377
- } catch (err) {
378
- console.error(
379
- `Failed to link agent ${anon.agentId}:`,
380
- err instanceof Error ? err.message : err
381
- );
382
- }
383
- }
384
- for (const id of toRemove) {
385
- removeAnonymousAgent(config, id);
386
- }
387
- saveConfig(config);
388
- if (linkedCount > 0) {
389
- console.log(`Linked ${linkedCount} agent(s) to your account.`);
390
- }
391
- }
343
+ if (err instanceof NonRetryableError) throw err;
344
+ lastError = err;
345
+ if (attempt < opts.maxAttempts - 1) {
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));
348
+ await sleep(delay, signal);
392
349
  }
393
- return;
394
350
  }
395
351
  }
396
- console.error("\nDevice code expired. Please run `opencara login` again.");
397
- process.exit(1);
398
- });
399
-
400
- // src/commands/agent.ts
401
- import { Command as Command2 } from "commander";
402
- import WebSocket from "ws";
403
- import crypto2 from "crypto";
404
-
405
- // ../shared/dist/api.js
406
- var DEFAULT_REGISTRY = {
407
- tools: [
408
- {
409
- name: "claude",
410
- displayName: "Claude",
411
- binary: "claude",
412
- commandTemplate: "claude --model ${MODEL} --allowedTools '*' --print",
413
- tokenParser: "claude"
414
- },
415
- {
416
- name: "codex",
417
- displayName: "Codex",
418
- binary: "codex",
419
- commandTemplate: "codex --model ${MODEL} exec",
420
- tokenParser: "codex"
421
- },
422
- {
423
- name: "gemini",
424
- displayName: "Gemini",
425
- binary: "gemini",
426
- commandTemplate: "gemini -m ${MODEL}",
427
- tokenParser: "gemini"
428
- },
429
- {
430
- name: "qwen",
431
- displayName: "Qwen",
432
- binary: "qwen",
433
- commandTemplate: "qwen --model ${MODEL} -y",
434
- tokenParser: "qwen"
435
- }
436
- ],
437
- models: [
438
- {
439
- name: "claude-opus-4-6",
440
- displayName: "Claude Opus 4.6",
441
- tools: ["claude"],
442
- defaultReputation: 0.8
443
- },
444
- {
445
- name: "claude-opus-4-6[1m]",
446
- displayName: "Claude Opus 4.6 (1M context)",
447
- tools: ["claude"],
448
- defaultReputation: 0.8
449
- },
450
- {
451
- name: "claude-sonnet-4-6",
452
- displayName: "Claude Sonnet 4.6",
453
- tools: ["claude"],
454
- defaultReputation: 0.7
455
- },
456
- {
457
- name: "claude-sonnet-4-6[1m]",
458
- displayName: "Claude Sonnet 4.6 (1M context)",
459
- tools: ["claude"],
460
- defaultReputation: 0.7
461
- },
462
- {
463
- name: "gpt-5-codex",
464
- displayName: "GPT-5 Codex",
465
- tools: ["codex"],
466
- defaultReputation: 0.7
467
- },
468
- {
469
- name: "gemini-2.5-pro",
470
- displayName: "Gemini 2.5 Pro",
471
- tools: ["gemini"],
472
- defaultReputation: 0.7
473
- },
474
- {
475
- name: "qwen3.5-plus",
476
- displayName: "Qwen 3.5 Plus",
477
- tools: ["qwen"],
478
- defaultReputation: 0.6
479
- },
480
- { name: "glm-5", displayName: "GLM-5", tools: ["qwen"], defaultReputation: 0.5 },
481
- { name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"], defaultReputation: 0.5 },
482
- {
483
- name: "minimax-m2.5",
484
- displayName: "Minimax M2.5",
485
- tools: ["qwen"],
486
- defaultReputation: 0.5
352
+ throw lastError;
353
+ }
354
+ function sleep(ms, signal) {
355
+ return new Promise((resolve2) => {
356
+ if (signal?.aborted) {
357
+ resolve2();
358
+ return;
487
359
  }
488
- ]
489
- };
490
-
491
- // ../shared/dist/review-config.js
492
- import { parse as parseYaml } from "yaml";
360
+ const timer = setTimeout(resolve2, ms);
361
+ signal?.addEventListener(
362
+ "abort",
363
+ () => {
364
+ clearTimeout(timer);
365
+ resolve2();
366
+ },
367
+ { once: true }
368
+ );
369
+ });
370
+ }
493
371
 
494
372
  // src/tool-executor.ts
495
- import { spawn, execFileSync } from "child_process";
496
- import * as fs2 from "fs";
497
- 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";
498
376
  var ToolTimeoutError = class extends Error {
499
377
  constructor(message) {
500
378
  super(message);
@@ -505,9 +383,9 @@ var MIN_PARTIAL_RESULT_LENGTH = 50;
505
383
  var MAX_STDERR_LENGTH = 1e3;
506
384
  function validateCommandBinary(commandTemplate) {
507
385
  const { command } = parseCommandTemplate(commandTemplate);
508
- if (path2.isAbsolute(command)) {
386
+ if (path3.isAbsolute(command)) {
509
387
  try {
510
- fs2.accessSync(command, fs2.constants.X_OK);
388
+ fs3.accessSync(command, fs3.constants.X_OK);
511
389
  return true;
512
390
  } catch {
513
391
  return false;
@@ -516,9 +394,9 @@ function validateCommandBinary(commandTemplate) {
516
394
  try {
517
395
  const isWindows = process.platform === "win32";
518
396
  if (isWindows) {
519
- execFileSync("where", [command], { stdio: "pipe" });
397
+ execFileSync2("where", [command], { stdio: "pipe" });
520
398
  } else {
521
- execFileSync("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
399
+ execFileSync2("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
522
400
  }
523
401
  return true;
524
402
  } catch {
@@ -560,14 +438,6 @@ function parseCommandTemplate(template, vars = {}) {
560
438
  }
561
439
  return { command: interpolated[0], args: interpolated.slice(1) };
562
440
  }
563
- function resolveCommandTemplate(agentCommand2) {
564
- if (agentCommand2) {
565
- return agentCommand2;
566
- }
567
- throw new Error(
568
- "No command configured for this agent. Set command in ~/.opencara/config.yml agents section or run `opencara agent create`."
569
- );
570
- }
571
441
  var CHARS_PER_TOKEN = 4;
572
442
  function estimateTokens(text) {
573
443
  return Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -589,9 +459,12 @@ function parseTokenUsage(stdout, stderr) {
589
459
  if (qwenMatch) return { tokens: parseInt(qwenMatch[1], 10), parsed: true };
590
460
  return { tokens: estimateTokens(stdout), parsed: false };
591
461
  }
592
- function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
462
+ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars, cwd) {
593
463
  const promptViaArg = commandTemplate.includes("${PROMPT}");
594
464
  const allVars = { ...vars, PROMPT: prompt };
465
+ if (cwd && !allVars["CODEBASE_DIR"]) {
466
+ allVars["CODEBASE_DIR"] = cwd;
467
+ }
595
468
  const { command, args } = parseCommandTemplate(commandTemplate, allVars);
596
469
  return new Promise((resolve2, reject) => {
597
470
  if (signal?.aborted) {
@@ -599,7 +472,8 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
599
472
  return;
600
473
  }
601
474
  const child = spawn(command, args, {
602
- stdio: ["pipe", "pipe", "pipe"]
475
+ stdio: ["pipe", "pipe", "pipe"],
476
+ cwd
603
477
  });
604
478
  let stdout = "";
605
479
  let stderr = "";
@@ -677,6 +551,26 @@ function executeTool(commandTemplate, prompt, timeoutMs, signal, vars) {
677
551
  });
678
552
  });
679
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
+ }
680
574
 
681
575
  // src/review.ts
682
576
  var TIMEOUT_SAFETY_MARGIN_MS = 3e4;
@@ -741,6 +635,7 @@ function extractVerdict(text) {
741
635
  const review = (before + after).replace(/\n{3,}/g, "\n\n").trim();
742
636
  return { verdict: verdictStr, review };
743
637
  }
638
+ console.warn("No verdict found in review output, defaulting to COMMENT");
744
639
  return { verdict: "comment", review: text };
745
640
  }
746
641
  async function executeReview(req, deps, runTool = executeTool) {
@@ -769,7 +664,9 @@ ${userMessage}`;
769
664
  deps.commandTemplate,
770
665
  fullPrompt,
771
666
  effectiveTimeout,
772
- abortController.signal
667
+ abortController.signal,
668
+ void 0,
669
+ deps.codebaseDir ?? void 0
773
670
  );
774
671
  const { verdict, review } = extractVerdict(result.stdout);
775
672
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
@@ -886,7 +783,9 @@ ${userMessage}`;
886
783
  deps.commandTemplate,
887
784
  fullPrompt,
888
785
  effectiveTimeout,
889
- abortController.signal
786
+ abortController.signal,
787
+ void 0,
788
+ deps.codebaseDir ?? void 0
890
789
  );
891
790
  const inputTokens = result.tokensParsed ? 0 : estimateTokens(fullPrompt);
892
791
  return {
@@ -900,7 +799,7 @@ ${userMessage}`;
900
799
  }
901
800
 
902
801
  // src/router.ts
903
- import * as readline2 from "readline";
802
+ import * as readline from "readline";
904
803
  var END_OF_RESPONSE = "<<<OPENCARA_END_RESPONSE>>>";
905
804
  var RouterRelay = class {
906
805
  pending = null;
@@ -918,7 +817,7 @@ var RouterRelay = class {
918
817
  /** Start listening for stdin input */
919
818
  start() {
920
819
  this.stopped = false;
921
- this.rl = readline2.createInterface({
820
+ this.rl = readline.createInterface({
922
821
  input: this.stdin,
923
822
  terminal: false
924
823
  });
@@ -928,15 +827,16 @@ var RouterRelay = class {
928
827
  this.rl.on("close", () => {
929
828
  if (this.stopped) return;
930
829
  if (this.pending) {
931
- const response = this.responseLines.join("\n");
830
+ const response = this.responseLines.join("\n").trim();
932
831
  this.responseLines = [];
933
832
  clearTimeout(this.pending.timer);
934
833
  const task = this.pending;
935
834
  this.pending = null;
936
- if (response.trim()) {
835
+ if (response.length >= 100) {
836
+ console.warn("Router stdin closed \u2014 accepting partial response");
937
837
  task.resolve(response);
938
838
  } else {
939
- task.reject(new Error("stdin closed with no response"));
839
+ task.reject(new Error("Router process died (stdin closed with insufficient response)"));
940
840
  }
941
841
  }
942
842
  });
@@ -957,7 +857,11 @@ var RouterRelay = class {
957
857
  }
958
858
  /** Write the prompt as plain text to stdout */
959
859
  writePrompt(prompt) {
960
- this.stdout.write(prompt + "\n");
860
+ try {
861
+ this.stdout.write(prompt + "\n");
862
+ } catch (err) {
863
+ throw new Error(`Failed to write to router: ${err.message}`);
864
+ }
961
865
  }
962
866
  /** Write a status message to stderr (doesn't interfere with prompt/response on stdout/stdin) */
963
867
  writeStatus(message) {
@@ -1001,7 +905,14 @@ ${userMessage}`;
1001
905
  reject(new RouterTimeoutError(`Response timeout (${timeoutSec}s)`));
1002
906
  }, timeoutMs);
1003
907
  this.pending = { resolve: resolve2, reject, timer };
1004
- this.writePrompt(prompt);
908
+ try {
909
+ this.writePrompt(prompt);
910
+ } catch (err) {
911
+ clearTimeout(timer);
912
+ this.pending = null;
913
+ this.responseLines = [];
914
+ reject(err);
915
+ }
1005
916
  });
1006
917
  }
1007
918
  /** Parse a review response: extract verdict and review text */
@@ -1016,11 +927,15 @@ ${userMessage}`;
1016
927
  handleLine(line) {
1017
928
  if (!this.pending) return;
1018
929
  if (line.trim() === END_OF_RESPONSE) {
1019
- const response = this.responseLines.join("\n");
930
+ const response = this.responseLines.join("\n").trim();
1020
931
  this.responseLines = [];
1021
932
  clearTimeout(this.pending.timer);
1022
933
  const task = this.pending;
1023
934
  this.pending = null;
935
+ if (!response || response.length < 10) {
936
+ task.reject(new Error("Router returned empty or trivially short response"));
937
+ return;
938
+ }
1024
939
  task.resolve(response);
1025
940
  return;
1026
941
  }
@@ -1035,9 +950,6 @@ var RouterTimeoutError = class extends Error {
1035
950
  };
1036
951
 
1037
952
  // src/consumption.ts
1038
- async function checkConsumptionLimits(_agentId, _limits) {
1039
- return { allowed: true };
1040
- }
1041
953
  function createSessionTracker() {
1042
954
  return { tokens: 0, reviews: 0 };
1043
955
  }
@@ -1045,1382 +957,712 @@ function recordSessionUsage(session, tokensUsed) {
1045
957
  session.tokens += tokensUsed;
1046
958
  session.reviews += 1;
1047
959
  }
1048
- function formatPostReviewStats(_tokensUsed, session, _limits, _dailyStats) {
960
+ function formatPostReviewStats(session) {
1049
961
  return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
1050
962
  }
1051
963
 
1052
964
  // src/commands/agent.ts
1053
- var CONNECTION_STABILITY_THRESHOLD_MS = 3e4;
1054
- function formatTable(agents, trustLabels) {
1055
- if (agents.length === 0) {
1056
- console.log("No agents registered. Run `opencara agent create` to register one.");
1057
- return;
1058
- }
1059
- const header = [
1060
- "ID".padEnd(38),
1061
- "Name".padEnd(20),
1062
- "Model".padEnd(22),
1063
- "Tool".padEnd(16),
1064
- "Status".padEnd(10),
1065
- "Trust"
1066
- ].join("");
1067
- console.log(header);
1068
- for (const a of agents) {
1069
- const trust = trustLabels?.get(a.id) ?? "--";
1070
- const name = a.displayName ?? "--";
1071
- console.log(
1072
- [
1073
- a.id.padEnd(38),
1074
- name.padEnd(20),
1075
- a.model.padEnd(22),
1076
- a.tool.padEnd(16),
1077
- a.status.padEnd(10),
1078
- trust
1079
- ].join("")
1080
- );
1081
- }
965
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
966
+ var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
967
+ var MAX_POLL_BACKOFF_MS = 3e5;
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
+ };
1082
975
  }
1083
- function buildWsUrl(platformUrl, agentId, apiKey) {
1084
- return platformUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + `/ws/agent/${agentId}?token=${encodeURIComponent(apiKey)}`;
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}`;
1085
982
  }
1086
- var HEARTBEAT_TIMEOUT_MS = 9e4;
1087
- var STABILITY_THRESHOLD_MIN_MS = 5e3;
1088
- var STABILITY_THRESHOLD_MAX_MS = 3e5;
1089
- var WS_PING_INTERVAL_MS = 2e4;
1090
- function startAgent(agentId, platformUrl, apiKey, reviewDeps, consumptionDeps, options) {
1091
- const verbose = options?.verbose ?? false;
1092
- const stabilityThreshold = options?.stabilityThresholdMs ?? CONNECTION_STABILITY_THRESHOLD_MS;
1093
- const repoConfig = options?.repoConfig;
1094
- const displayName = options?.displayName;
1095
- const routerRelay = options?.routerRelay;
1096
- const prefix = options?.label ? `[${options.label}]` : "";
1097
- const log = (...args) => console.log(...prefix ? [prefix, ...args] : args);
1098
- const logError = (...args) => console.error(...prefix ? [prefix, ...args] : args);
1099
- let attempt = 0;
1100
- let intentionalClose = false;
1101
- let heartbeatTimer = null;
1102
- let wsPingTimer = null;
1103
- let currentWs = null;
1104
- let connectionOpenedAt = null;
1105
- let stabilityTimer = null;
1106
- function clearHeartbeatTimer() {
1107
- if (heartbeatTimer) {
1108
- clearTimeout(heartbeatTimer);
1109
- heartbeatTimer = null;
1110
- }
1111
- }
1112
- function clearStabilityTimer() {
1113
- if (stabilityTimer) {
1114
- clearTimeout(stabilityTimer);
1115
- stabilityTimer = null;
1116
- }
1117
- }
1118
- function clearWsPingTimer() {
1119
- if (wsPingTimer) {
1120
- clearInterval(wsPingTimer);
1121
- wsPingTimer = null;
1122
- }
1123
- }
1124
- function shutdown() {
1125
- intentionalClose = true;
1126
- clearHeartbeatTimer();
1127
- clearStabilityTimer();
1128
- clearWsPingTimer();
1129
- if (currentWs) currentWs.close();
1130
- log("Disconnected.");
1131
- process.exit(0);
1132
- }
1133
- process.once("SIGINT", shutdown);
1134
- process.once("SIGTERM", shutdown);
1135
- function connect() {
1136
- const url = buildWsUrl(platformUrl, agentId, apiKey);
1137
- const ws = new WebSocket(url);
1138
- currentWs = ws;
1139
- function resetHeartbeatTimer() {
1140
- clearHeartbeatTimer();
1141
- heartbeatTimer = setTimeout(() => {
1142
- log("No heartbeat received in 90s. Reconnecting...");
1143
- ws.terminate();
1144
- }, HEARTBEAT_TIMEOUT_MS);
1145
- }
1146
- ws.on("open", () => {
1147
- connectionOpenedAt = Date.now();
1148
- log("Connected to platform.");
1149
- resetHeartbeatTimer();
1150
- clearWsPingTimer();
1151
- wsPingTimer = setInterval(() => {
1152
- try {
1153
- if (ws.readyState === WebSocket.OPEN) {
1154
- ws.ping();
1155
- }
1156
- } catch {
983
+ async function fetchDiff(diffUrl, githubToken, signal) {
984
+ return withRetry(
985
+ async () => {
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}`;
1157
997
  }
1158
- }, WS_PING_INTERVAL_MS);
1159
- if (verbose) {
1160
- log(`[verbose] Connection opened at ${new Date(connectionOpenedAt).toISOString()}`);
1161
998
  }
1162
- clearStabilityTimer();
1163
- stabilityTimer = setTimeout(() => {
1164
- if (verbose) {
1165
- log(
1166
- `[verbose] Connection stable for ${stabilityThreshold / 1e3}s \u2014 resetting reconnect counter`
1167
- );
999
+ const response = await fetch(url, { headers, signal });
1000
+ if (!response.ok) {
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}`);
1168
1005
  }
1169
- attempt = 0;
1170
- }, stabilityThreshold);
1171
- });
1172
- ws.on("message", (data) => {
1173
- let msg;
1174
- try {
1175
- msg = JSON.parse(data.toString());
1176
- } catch {
1177
- return;
1178
- }
1179
- handleMessage(
1180
- ws,
1181
- msg,
1182
- resetHeartbeatTimer,
1183
- reviewDeps,
1184
- consumptionDeps,
1185
- verbose,
1186
- repoConfig,
1187
- displayName,
1188
- prefix,
1189
- routerRelay
1190
- );
1191
- });
1192
- ws.on("close", (code, reason) => {
1193
- if (intentionalClose) return;
1194
- if (ws !== currentWs) return;
1195
- clearHeartbeatTimer();
1196
- clearStabilityTimer();
1197
- clearWsPingTimer();
1198
- if (connectionOpenedAt) {
1199
- const lifetimeMs = Date.now() - connectionOpenedAt;
1200
- const lifetimeSec = (lifetimeMs / 1e3).toFixed(1);
1201
- log(
1202
- `Disconnected (code=${code}, reason=${reason.toString()}). Connection was alive for ${lifetimeSec}s.`
1203
- );
1204
- } else {
1205
- log(`Disconnected (code=${code}, reason=${reason.toString()}).`);
1206
- }
1207
- if (code === 4002) {
1208
- log("Connection replaced by server \u2014 not reconnecting.");
1209
- return;
1210
- }
1211
- connectionOpenedAt = null;
1212
- reconnect();
1213
- });
1214
- ws.on("pong", () => {
1215
- if (verbose) {
1216
- log(`[verbose] WS pong received at ${(/* @__PURE__ */ new Date()).toISOString()}`);
1006
+ throw new Error(msg);
1217
1007
  }
1218
- });
1219
- ws.on("error", (err) => {
1220
- logError(`WebSocket error: ${err.message}`);
1221
- });
1222
- }
1223
- async function reconnect() {
1224
- const delay = calculateDelay(attempt, DEFAULT_RECONNECT_OPTIONS);
1225
- const delaySec = (delay / 1e3).toFixed(1);
1226
- attempt++;
1227
- log(`Reconnecting in ${delaySec}s... (attempt ${attempt})`);
1228
- await sleep(delay);
1229
- connect();
1230
- }
1231
- connect();
1232
- }
1233
- function trySend(ws, data) {
1234
- try {
1235
- ws.send(JSON.stringify(data));
1236
- } catch {
1237
- console.error("Failed to send message \u2014 WebSocket may be closed");
1238
- }
1239
- }
1240
- async function logPostReviewStats(type, verdict, tokensUsed, tokensEstimated, consumptionDeps, logPrefix) {
1241
- const pfx = logPrefix ? `${logPrefix} ` : "";
1242
- const estimateTag = tokensEstimated ? " ~" : " ";
1243
- if (!consumptionDeps) {
1244
- if (verdict) {
1245
- console.log(
1246
- `${pfx}${type} complete: ${verdict} (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
1247
- );
1248
- } else {
1249
- console.log(
1250
- `${pfx}${type} complete (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
1251
- );
1252
- }
1253
- return;
1254
- }
1255
- recordSessionUsage(consumptionDeps.session, tokensUsed);
1256
- if (verdict) {
1257
- console.log(
1258
- `${pfx}${type} complete: ${verdict} (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
1259
- );
1260
- } else {
1261
- console.log(
1262
- `${pfx}${type} complete (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
1263
- );
1264
- }
1265
- console.log(
1266
- `${pfx}${formatPostReviewStats(tokensUsed, consumptionDeps.session, consumptionDeps.limits)}`
1008
+ return response.text();
1009
+ },
1010
+ { maxAttempts: 2 },
1011
+ signal
1267
1012
  );
1268
1013
  }
1269
- function handleMessage(ws, msg, resetHeartbeat, reviewDeps, consumptionDeps, verbose, repoConfig, displayName, logPrefix, routerRelay) {
1270
- const pfx = logPrefix ? `${logPrefix} ` : "";
1271
- switch (msg.type) {
1272
- case "connected":
1273
- console.log(`${pfx}Authenticated. Protocol v${msg.version ?? "unknown"}`);
1274
- trySend(ws, {
1275
- type: "agent_preferences",
1276
- id: crypto2.randomUUID(),
1277
- timestamp: Date.now(),
1278
- ...displayName ? { displayName } : {},
1279
- repoConfig: repoConfig ?? { mode: "all" }
1280
- });
1281
- if (routerRelay) {
1282
- routerRelay.writeStatus("Waiting for review requests...");
1283
- }
1284
- break;
1285
- case "heartbeat_ping":
1286
- ws.send(JSON.stringify({ type: "heartbeat_pong", timestamp: Date.now() }));
1287
- if (verbose) {
1288
- console.log(
1289
- `${pfx}[verbose] Heartbeat ping received, pong sent at ${(/* @__PURE__ */ new Date()).toISOString()}`
1290
- );
1291
- }
1292
- if (resetHeartbeat) resetHeartbeat();
1293
- break;
1294
- case "review_request": {
1295
- const request = msg;
1296
- console.log(
1297
- `${pfx}Review request: task ${request.taskId} for ${request.project.owner}/${request.project.repo}#${request.pr.number}`
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...`);
1019
+ let consecutiveAuthErrors = 0;
1020
+ let consecutiveErrors = 0;
1021
+ const diffFailCounts = /* @__PURE__ */ new Map();
1022
+ while (!signal?.aborted) {
1023
+ try {
1024
+ const pollBody = { agent_id: agentId };
1025
+ if (reviewOnly) pollBody.review_only = true;
1026
+ const pollResponse = await client.post("/api/tasks/poll", pollBody);
1027
+ consecutiveAuthErrors = 0;
1028
+ consecutiveErrors = 0;
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
1298
1032
  );
1299
- if (routerRelay) {
1300
- void (async () => {
1301
- if (consumptionDeps) {
1302
- const limitResult = await checkConsumptionLimits(
1303
- consumptionDeps.agentId,
1304
- consumptionDeps.limits
1305
- );
1306
- if (!limitResult.allowed) {
1307
- trySend(ws, {
1308
- type: "review_rejected",
1309
- id: crypto2.randomUUID(),
1310
- timestamp: Date.now(),
1311
- taskId: request.taskId,
1312
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1313
- });
1314
- console.log(`${pfx}Review rejected: ${limitResult.reason}`);
1315
- return;
1316
- }
1317
- }
1318
- try {
1319
- const prompt = routerRelay.buildReviewPrompt({
1320
- owner: request.project.owner,
1321
- repo: request.project.repo,
1322
- reviewMode: request.reviewMode ?? "full",
1323
- prompt: request.project.prompt,
1324
- diffContent: request.diffContent
1325
- });
1326
- const response = await routerRelay.sendPrompt(
1327
- "review_request",
1328
- request.taskId,
1329
- prompt,
1330
- request.timeout
1331
- );
1332
- const { verdict, review } = routerRelay.parseReviewResponse(response);
1333
- const tokensUsed = estimateTokens(prompt) + estimateTokens(response);
1334
- trySend(ws, {
1335
- type: "review_complete",
1336
- id: crypto2.randomUUID(),
1337
- timestamp: Date.now(),
1338
- taskId: request.taskId,
1339
- review,
1340
- verdict,
1341
- tokensUsed
1342
- });
1343
- await logPostReviewStats(
1344
- "Review",
1345
- verdict,
1346
- tokensUsed,
1347
- true,
1348
- consumptionDeps,
1349
- logPrefix
1350
- );
1351
- } catch (err) {
1352
- if (err instanceof RouterTimeoutError) {
1353
- trySend(ws, {
1354
- type: "review_error",
1355
- id: crypto2.randomUUID(),
1356
- timestamp: Date.now(),
1357
- taskId: request.taskId,
1358
- error: err.message
1359
- });
1360
- } else {
1361
- trySend(ws, {
1362
- type: "review_error",
1363
- id: crypto2.randomUUID(),
1364
- timestamp: Date.now(),
1365
- taskId: request.taskId,
1366
- error: err instanceof Error ? err.message : "Unknown error"
1367
- });
1368
- }
1369
- console.error(`${pfx}Review failed:`, err);
1370
- }
1371
- })();
1372
- break;
1373
- }
1374
- if (!reviewDeps) {
1375
- ws.send(
1376
- JSON.stringify({
1377
- type: "review_rejected",
1378
- id: crypto2.randomUUID(),
1379
- timestamp: Date.now(),
1380
- taskId: request.taskId,
1381
- reason: "Review execution not configured"
1382
- })
1033
+ if (task) {
1034
+ const result = await handleTask(
1035
+ client,
1036
+ agentId,
1037
+ task,
1038
+ reviewDeps,
1039
+ consumptionDeps,
1040
+ agentInfo,
1041
+ logger,
1042
+ routerRelay,
1043
+ signal
1383
1044
  );
1384
- break;
1385
- }
1386
- void (async () => {
1387
- if (consumptionDeps) {
1388
- const limitResult = await checkConsumptionLimits(
1389
- consumptionDeps.agentId,
1390
- consumptionDeps.limits
1391
- );
1392
- if (!limitResult.allowed) {
1393
- trySend(ws, {
1394
- type: "review_rejected",
1395
- id: crypto2.randomUUID(),
1396
- timestamp: Date.now(),
1397
- taskId: request.taskId,
1398
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1399
- });
1400
- console.log(`${pfx}Review rejected: ${limitResult.reason}`);
1401
- return;
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`);
1402
1050
  }
1403
1051
  }
1404
- try {
1405
- const result = await executeReview(
1406
- {
1407
- taskId: request.taskId,
1408
- diffContent: request.diffContent,
1409
- prompt: request.project.prompt,
1410
- owner: request.project.owner,
1411
- repo: request.project.repo,
1412
- prNumber: request.pr.number,
1413
- timeout: request.timeout,
1414
- reviewMode: request.reviewMode ?? "full"
1415
- },
1416
- reviewDeps
1417
- );
1418
- trySend(ws, {
1419
- type: "review_complete",
1420
- id: crypto2.randomUUID(),
1421
- timestamp: Date.now(),
1422
- taskId: request.taskId,
1423
- review: result.review,
1424
- verdict: result.verdict,
1425
- tokensUsed: result.tokensUsed
1426
- });
1427
- await logPostReviewStats(
1428
- "Review",
1429
- result.verdict,
1430
- result.tokensUsed,
1431
- result.tokensEstimated,
1432
- consumptionDeps,
1433
- logPrefix
1434
- );
1435
- } catch (err) {
1436
- if (err instanceof DiffTooLargeError) {
1437
- trySend(ws, {
1438
- type: "review_rejected",
1439
- id: crypto2.randomUUID(),
1440
- timestamp: Date.now(),
1441
- taskId: request.taskId,
1442
- reason: err.message
1443
- });
1444
- } else {
1445
- trySend(ws, {
1446
- type: "review_error",
1447
- id: crypto2.randomUUID(),
1448
- timestamp: Date.now(),
1449
- taskId: request.taskId,
1450
- error: err instanceof Error ? err.message : "Unknown error"
1451
- });
1452
- }
1453
- console.error(`${pfx}Review failed:`, err);
1052
+ }
1053
+ } catch (err) {
1054
+ if (signal?.aborted) break;
1055
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
1056
+ consecutiveAuthErrors++;
1057
+ consecutiveErrors++;
1058
+ logError(
1059
+ `Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
1060
+ );
1061
+ if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
1062
+ logError("Authentication failed repeatedly. Exiting.");
1063
+ break;
1454
1064
  }
1455
- })();
1456
- break;
1457
- }
1458
- case "summary_request": {
1459
- const summaryRequest = msg;
1460
- console.log(
1461
- `${pfx}Summary request: task ${summaryRequest.taskId} for ${summaryRequest.project.owner}/${summaryRequest.project.repo}#${summaryRequest.pr.number} (${summaryRequest.reviews.length} reviews)`
1462
- );
1463
- if (routerRelay) {
1464
- void (async () => {
1465
- if (consumptionDeps) {
1466
- const limitResult = await checkConsumptionLimits(
1467
- consumptionDeps.agentId,
1468
- consumptionDeps.limits
1469
- );
1470
- if (!limitResult.allowed) {
1471
- trySend(ws, {
1472
- type: "review_rejected",
1473
- id: crypto2.randomUUID(),
1474
- timestamp: Date.now(),
1475
- taskId: summaryRequest.taskId,
1476
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1477
- });
1478
- console.log(`${pfx}Summary rejected: ${limitResult.reason}`);
1479
- return;
1480
- }
1481
- }
1482
- try {
1483
- const prompt = routerRelay.buildSummaryPrompt({
1484
- owner: summaryRequest.project.owner,
1485
- repo: summaryRequest.project.repo,
1486
- prompt: summaryRequest.project.prompt,
1487
- reviews: summaryRequest.reviews,
1488
- diffContent: summaryRequest.diffContent ?? ""
1489
- });
1490
- const response = await routerRelay.sendPrompt(
1491
- "summary_request",
1492
- summaryRequest.taskId,
1493
- prompt,
1494
- summaryRequest.timeout
1495
- );
1496
- const tokensUsed = estimateTokens(prompt) + estimateTokens(response);
1497
- trySend(ws, {
1498
- type: "summary_complete",
1499
- id: crypto2.randomUUID(),
1500
- timestamp: Date.now(),
1501
- taskId: summaryRequest.taskId,
1502
- summary: response,
1503
- tokensUsed
1504
- });
1505
- await logPostReviewStats(
1506
- "Summary",
1507
- void 0,
1508
- tokensUsed,
1509
- true,
1510
- consumptionDeps,
1511
- logPrefix
1512
- );
1513
- } catch (err) {
1514
- if (err instanceof RouterTimeoutError) {
1515
- trySend(ws, {
1516
- type: "review_error",
1517
- id: crypto2.randomUUID(),
1518
- timestamp: Date.now(),
1519
- taskId: summaryRequest.taskId,
1520
- error: err.message
1521
- });
1522
- } else {
1523
- trySend(ws, {
1524
- type: "review_error",
1525
- id: crypto2.randomUUID(),
1526
- timestamp: Date.now(),
1527
- taskId: summaryRequest.taskId,
1528
- error: err instanceof Error ? err.message : "Summary failed"
1529
- });
1530
- }
1531
- console.error(`${pfx}Summary failed:`, err);
1532
- }
1533
- })();
1534
- break;
1065
+ } else {
1066
+ consecutiveAuthErrors = 0;
1067
+ consecutiveErrors++;
1068
+ logError(`Poll error: ${err.message}`);
1535
1069
  }
1536
- if (!reviewDeps) {
1537
- trySend(ws, {
1538
- type: "review_rejected",
1539
- id: crypto2.randomUUID(),
1540
- timestamp: Date.now(),
1541
- taskId: summaryRequest.taskId,
1542
- reason: "Review tool not configured"
1543
- });
1070
+ if (consecutiveErrors >= maxConsecutiveErrors) {
1071
+ logError(
1072
+ `Too many consecutive errors (${consecutiveErrors}/${maxConsecutiveErrors}). Shutting down.`
1073
+ );
1074
+ process.exitCode = 1;
1544
1075
  break;
1545
1076
  }
1546
- void (async () => {
1547
- if (consumptionDeps) {
1548
- const limitResult = await checkConsumptionLimits(
1549
- consumptionDeps.agentId,
1550
- consumptionDeps.limits
1551
- );
1552
- if (!limitResult.allowed) {
1553
- trySend(ws, {
1554
- type: "review_rejected",
1555
- id: crypto2.randomUUID(),
1556
- timestamp: Date.now(),
1557
- taskId: summaryRequest.taskId,
1558
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1559
- });
1560
- console.log(`${pfx}Summary rejected: ${limitResult.reason}`);
1561
- return;
1562
- }
1563
- }
1564
- try {
1565
- const result = await executeSummary(
1566
- {
1567
- taskId: summaryRequest.taskId,
1568
- reviews: summaryRequest.reviews,
1569
- prompt: summaryRequest.project.prompt,
1570
- owner: summaryRequest.project.owner,
1571
- repo: summaryRequest.project.repo,
1572
- prNumber: summaryRequest.pr.number,
1573
- timeout: summaryRequest.timeout,
1574
- diffContent: summaryRequest.diffContent ?? ""
1575
- },
1576
- reviewDeps
1577
- );
1578
- trySend(ws, {
1579
- type: "summary_complete",
1580
- id: crypto2.randomUUID(),
1581
- timestamp: Date.now(),
1582
- taskId: summaryRequest.taskId,
1583
- summary: result.summary,
1584
- tokensUsed: result.tokensUsed
1585
- });
1586
- await logPostReviewStats(
1587
- "Summary",
1588
- void 0,
1589
- result.tokensUsed,
1590
- result.tokensEstimated,
1591
- consumptionDeps,
1592
- logPrefix
1077
+ if (consecutiveErrors > 0) {
1078
+ const backoff = Math.min(
1079
+ pollIntervalMs * Math.pow(2, consecutiveErrors - 1),
1080
+ MAX_POLL_BACKOFF_MS
1081
+ );
1082
+ const extraDelay = backoff - pollIntervalMs;
1083
+ if (extraDelay > 0) {
1084
+ logWarn(
1085
+ `Poll failed (${consecutiveErrors} consecutive). Next poll in ${Math.round(backoff / 1e3)}s`
1593
1086
  );
1594
- } catch (err) {
1595
- if (err instanceof InputTooLargeError) {
1596
- trySend(ws, {
1597
- type: "review_rejected",
1598
- id: crypto2.randomUUID(),
1599
- timestamp: Date.now(),
1600
- taskId: summaryRequest.taskId,
1601
- reason: err.message
1602
- });
1603
- } else {
1604
- trySend(ws, {
1605
- type: "review_error",
1606
- id: crypto2.randomUUID(),
1607
- timestamp: Date.now(),
1608
- taskId: summaryRequest.taskId,
1609
- error: err instanceof Error ? err.message : "Summary failed"
1610
- });
1611
- }
1612
- console.error(`${pfx}Summary failed:`, err);
1087
+ await sleep2(extraDelay, signal);
1613
1088
  }
1614
- })();
1615
- break;
1089
+ }
1616
1090
  }
1617
- case "error":
1618
- console.error(`${pfx}Platform error: ${msg.code ?? "unknown"}`);
1619
- if (msg.code === "auth_revoked") process.exit(1);
1620
- break;
1621
- default:
1622
- break;
1091
+ await sleep2(pollIntervalMs, signal);
1623
1092
  }
1624
1093
  }
1625
- async function syncAgentToServer(client, serverAgents, localAgent) {
1626
- const existing = serverAgents.find(
1627
- (a) => a.model === localAgent.model && a.tool === localAgent.tool
1628
- );
1629
- if (existing) {
1630
- return { agentId: existing.id, created: false };
1094
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
1095
+ const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
1096
+ const { log, logError, logWarn } = logger;
1097
+ log(`
1098
+ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1099
+ log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
1100
+ let claimResponse;
1101
+ try {
1102
+ claimResponse = await withRetry(
1103
+ () => client.post(`/api/tasks/${task_id}/claim`, {
1104
+ agent_id: agentId,
1105
+ role,
1106
+ model: agentInfo.model,
1107
+ tool: agentInfo.tool
1108
+ }),
1109
+ { maxAttempts: 2 },
1110
+ signal
1111
+ );
1112
+ } catch (err) {
1113
+ const status = err instanceof HttpError ? ` (${err.status})` : "";
1114
+ logError(` Failed to claim task ${task_id}${status}: ${err.message}`);
1115
+ return {};
1631
1116
  }
1632
- const body = { model: localAgent.model, tool: localAgent.tool };
1633
- if (localAgent.name) {
1634
- body.displayName = localAgent.name;
1117
+ if (!claimResponse.claimed) {
1118
+ log(` Claim rejected: ${claimResponse.reason}`);
1119
+ return {};
1635
1120
  }
1636
- if (localAgent.repos) {
1637
- body.repoConfig = localAgent.repos;
1121
+ log(` Claimed as ${role}`);
1122
+ let diffContent;
1123
+ try {
1124
+ diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
1125
+ log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
1126
+ } catch (err) {
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 };
1638
1136
  }
1639
- const created = await client.post("/api/agents", body);
1640
- return { agentId: created.id, created: true };
1641
- }
1642
- function resolveLocalAgentCommand(localAgent, globalAgentCommand) {
1643
- const effectiveCommand = localAgent.command ?? globalAgentCommand;
1644
- return resolveCommandTemplate(effectiveCommand);
1645
- }
1646
- function startAgentRouter() {
1647
- void agentCommand.parseAsync(
1648
- ["start", "--router", "--anonymous", "--model", "router", "--tool", "opencara"],
1649
- { from: "user" }
1650
- );
1651
- }
1652
- var agentCommand = new Command2("agent").description("Manage review agents");
1653
- agentCommand.command("create").description("Add an agent to local config (interactive or via flags)").option("--model <model>", "AI model name (e.g., claude-opus-4-6)").option("--tool <tool>", "Review tool name (e.g., claude-code)").option("--command <cmd>", "Custom command template (bypasses registry lookup)").action(async (opts) => {
1654
- const config = loadConfig();
1655
- requireApiKey(config);
1656
- let model;
1657
- let tool;
1658
- let command = opts.command;
1659
- if (opts.model && opts.tool) {
1660
- model = opts.model;
1661
- tool = opts.tool;
1662
- } else if (opts.model || opts.tool) {
1663
- console.error("Both --model and --tool are required in non-interactive mode.");
1664
- process.exit(1);
1665
- } else {
1666
- const client = new ApiClient(config.platformUrl, config.apiKey);
1667
- let registry;
1137
+ let taskReviewDeps = reviewDeps;
1138
+ let taskCheckoutPath = null;
1139
+ if (reviewDeps.codebaseDir) {
1668
1140
  try {
1669
- registry = await client.get("/api/registry");
1670
- } catch {
1671
- console.warn("Could not fetch registry from server. Using built-in defaults.");
1672
- registry = DEFAULT_REGISTRY;
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 };
1673
1157
  }
1674
- const { search, input } = await import("@inquirer/prompts");
1675
- const searchTheme = {
1676
- style: {
1677
- keysHelpTip: (keys) => keys.map(([key, action]) => `${key} ${action}`).join(", ") + ", ^C exit"
1678
- }
1679
- };
1680
- const existingAgents = config.agents ?? [];
1681
- const toolChoices = registry.tools.map((t) => ({
1682
- name: t.displayName,
1683
- value: t.name
1684
- }));
1158
+ } else {
1685
1159
  try {
1686
- while (true) {
1687
- tool = await search({
1688
- message: "Select a tool:",
1689
- theme: searchTheme,
1690
- source: (term) => {
1691
- const q = (term ?? "").toLowerCase();
1692
- return toolChoices.filter(
1693
- (c) => c.name.toLowerCase().includes(q) || c.value.toLowerCase().includes(q)
1694
- );
1695
- }
1696
- });
1697
- const compatible = registry.models.filter((m) => m.tools.includes(tool));
1698
- const incompatible = registry.models.filter((m) => !m.tools.includes(tool));
1699
- const modelChoices = [
1700
- ...compatible.map((m) => ({
1701
- name: m.displayName,
1702
- value: m.name
1703
- })),
1704
- ...incompatible.map((m) => ({
1705
- name: `\x1B[38;5;249m${m.displayName}\x1B[0m`,
1706
- value: m.name
1707
- }))
1708
- ];
1709
- model = await search({
1710
- message: "Select a model:",
1711
- theme: searchTheme,
1712
- source: (term) => {
1713
- const q = (term ?? "").toLowerCase();
1714
- return modelChoices.filter(
1715
- (c) => c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)
1716
- );
1717
- }
1718
- });
1719
- const isDup = existingAgents.some((a) => a.model === model && a.tool === tool);
1720
- if (isDup) {
1721
- console.warn(`"${model}" / "${tool}" already exists in config. Choose again.`);
1722
- continue;
1723
- }
1724
- const modelEntry = registry.models.find((m) => m.name === model);
1725
- if (modelEntry && !modelEntry.tools.includes(tool)) {
1726
- console.warn(`Warning: "${model}" is not listed as compatible with "${tool}".`);
1727
- }
1728
- break;
1729
- }
1730
- const toolEntry = registry.tools.find((t) => t.name === tool);
1731
- const defaultCommand = toolEntry ? toolEntry.commandTemplate.replaceAll("${MODEL}", model) : `${tool} --model ${model}`;
1732
- command = await input({
1733
- message: "Command:",
1734
- default: defaultCommand,
1735
- prefill: "editable"
1736
- });
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}`);
1737
1168
  } catch (err) {
1738
- if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
1739
- console.log("Cancelled.");
1740
- return;
1741
- }
1742
- throw err;
1169
+ logWarn(
1170
+ ` Warning: failed to create working directory: ${err.message}. Continuing without scoped cwd.`
1171
+ );
1743
1172
  }
1744
1173
  }
1745
- if (!command) {
1746
- const toolEntry = DEFAULT_REGISTRY.tools.find((t) => t.name === tool);
1747
- if (toolEntry) {
1748
- command = toolEntry.commandTemplate.replaceAll("${MODEL}", model);
1174
+ try {
1175
+ if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
1176
+ await executeSummaryTask(
1177
+ client,
1178
+ agentId,
1179
+ task_id,
1180
+ owner,
1181
+ repo,
1182
+ pr_number,
1183
+ diffContent,
1184
+ prompt,
1185
+ timeout_seconds,
1186
+ claimResponse.reviews,
1187
+ taskReviewDeps,
1188
+ consumptionDeps,
1189
+ logger,
1190
+ routerRelay,
1191
+ signal
1192
+ );
1749
1193
  } else {
1750
- console.error(`No command template for tool "${tool}". Use --command to specify one.`);
1751
- process.exit(1);
1194
+ await executeReviewTask(
1195
+ client,
1196
+ agentId,
1197
+ task_id,
1198
+ owner,
1199
+ repo,
1200
+ pr_number,
1201
+ diffContent,
1202
+ prompt,
1203
+ timeout_seconds,
1204
+ taskReviewDeps,
1205
+ consumptionDeps,
1206
+ logger,
1207
+ routerRelay,
1208
+ signal
1209
+ );
1752
1210
  }
1753
- }
1754
- if (validateCommandBinary(command)) {
1755
- console.log(`Verifying... binary found.`);
1756
- } else {
1757
- console.warn(
1758
- `Warning: binary for command "${command.split(" ")[0]}" not found on this machine.`
1759
- );
1760
- }
1761
- const newAgent = { model, tool, command };
1762
- if (config.agents === null) {
1763
- config.agents = [];
1764
- }
1765
- const isDuplicate = config.agents.some((a) => a.model === model && a.tool === tool);
1766
- if (isDuplicate) {
1767
- console.error(`Agent with model "${model}" and tool "${tool}" already exists in config.`);
1768
- process.exit(1);
1769
- }
1770
- config.agents.push(newAgent);
1771
- saveConfig(config);
1772
- console.log("Agent added to config:");
1773
- console.log(` Model: ${model}`);
1774
- console.log(` Tool: ${tool}`);
1775
- console.log(` Command: ${command}`);
1776
- });
1777
- agentCommand.command("init").description("Import server-side agents into local config").action(async () => {
1778
- const config = loadConfig();
1779
- const apiKey = requireApiKey(config);
1780
- const client = new ApiClient(config.platformUrl, apiKey);
1781
- let res;
1782
- try {
1783
- res = await client.get("/api/agents");
1784
1211
  } catch (err) {
1785
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1786
- process.exit(1);
1787
- }
1788
- if (res.agents.length === 0) {
1789
- console.log("No server-side agents found. Use `opencara agent create` to add one.");
1790
- return;
1791
- }
1792
- let registry;
1793
- try {
1794
- registry = await client.get("/api/registry");
1795
- } catch {
1796
- registry = DEFAULT_REGISTRY;
1797
- }
1798
- const toolCommands = new Map(registry.tools.map((t) => [t.name, t.commandTemplate]));
1799
- const existing = config.agents ?? [];
1800
- let imported = 0;
1801
- for (const agent of res.agents) {
1802
- const isDuplicate = existing.some((e) => e.model === agent.model && e.tool === agent.tool);
1803
- if (isDuplicate) continue;
1804
- let command = toolCommands.get(agent.tool);
1805
- if (command) {
1806
- command = command.replaceAll("${MODEL}", agent.model);
1212
+ if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
1213
+ logError(` ${err.message}`);
1214
+ await safeReject(client, task_id, agentId, err.message, logger);
1807
1215
  } else {
1808
- console.warn(
1809
- `Warning: no command template for ${agent.model}/${agent.tool} \u2014 set command manually in config`
1810
- );
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);
1811
1222
  }
1812
- existing.push({ model: agent.model, tool: agent.tool, command });
1813
- imported++;
1814
- }
1815
- config.agents = existing;
1816
- saveConfig(config);
1817
- console.log(`Imported ${imported} agent(s) to local config.`);
1818
- if (imported > 0) {
1819
- console.log("Edit ~/.opencara/config.yml to adjust commands for your system.");
1820
1223
  }
1821
- });
1822
- agentCommand.command("list").description("List registered agents").action(async () => {
1823
- const config = loadConfig();
1824
- const apiKey = requireApiKey(config);
1825
- const client = new ApiClient(config.platformUrl, apiKey);
1826
- let res;
1224
+ return {};
1225
+ }
1226
+ async function safeReject(client, taskId, agentId, reason, logger) {
1827
1227
  try {
1828
- res = await client.get("/api/agents");
1228
+ await withRetry(
1229
+ () => client.post(`/api/tasks/${taskId}/reject`, {
1230
+ agent_id: agentId,
1231
+ reason: sanitizeTokens(reason)
1232
+ }),
1233
+ { maxAttempts: 2 }
1234
+ );
1829
1235
  } catch (err) {
1830
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1831
- process.exit(1);
1236
+ logger.logError(
1237
+ ` Failed to report rejection for task ${taskId}: ${err.message} (logged locally)`
1238
+ );
1832
1239
  }
1833
- const trustLabels = /* @__PURE__ */ new Map();
1834
- for (const agent of res.agents) {
1835
- try {
1836
- const stats = await client.get(`/api/stats/${agent.id}`);
1837
- trustLabels.set(agent.id, stats.agent.trustTier.label);
1838
- } catch {
1839
- }
1240
+ }
1241
+ async function safeError(client, taskId, agentId, error, logger) {
1242
+ try {
1243
+ await withRetry(
1244
+ () => client.post(`/api/tasks/${taskId}/error`, {
1245
+ agent_id: agentId,
1246
+ error: sanitizeTokens(error)
1247
+ }),
1248
+ { maxAttempts: 2 }
1249
+ );
1250
+ } catch (err) {
1251
+ logger.logError(
1252
+ ` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
1253
+ );
1840
1254
  }
1841
- formatTable(res.agents, trustLabels);
1842
- });
1843
- async function resolveAnonymousAgent(config, model, tool) {
1844
- const existing = config.anonymousAgents.find((a) => a.model === model && a.tool === tool);
1845
- if (existing) {
1846
- console.log(`Reusing stored anonymous agent ${existing.agentId} (${model} / ${tool})`);
1847
- const command2 = resolveCommandTemplate(
1848
- DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
1255
+ }
1256
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
1257
+ let reviewText;
1258
+ let verdict;
1259
+ let tokensUsed;
1260
+ if (routerRelay) {
1261
+ logger.log(` Executing review command: [router mode]`);
1262
+ const fullPrompt = routerRelay.buildReviewPrompt({
1263
+ owner,
1264
+ repo,
1265
+ reviewMode: "full",
1266
+ prompt,
1267
+ diffContent
1268
+ });
1269
+ const response = await routerRelay.sendPrompt(
1270
+ "review_request",
1271
+ taskId,
1272
+ fullPrompt,
1273
+ timeoutSeconds
1849
1274
  );
1850
- return { entry: existing, command: command2 };
1851
- }
1852
- console.log("Registering anonymous agent...");
1853
- const client = new ApiClient(config.platformUrl);
1854
- const body = { model, tool };
1855
- const res = await client.post("/api/agents/anonymous", body);
1856
- const entry = {
1857
- agentId: res.agentId,
1858
- apiKey: res.apiKey,
1859
- model,
1860
- tool
1861
- };
1862
- config.anonymousAgents.push(entry);
1863
- saveConfig(config);
1864
- console.log(`Agent registered: ${res.agentId} (${model} / ${tool})`);
1865
- console.log("Credentials saved to ~/.opencara/config.yml");
1866
- const command = resolveCommandTemplate(
1867
- DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
1275
+ const parsed = routerRelay.parseReviewResponse(response);
1276
+ reviewText = parsed.review;
1277
+ verdict = parsed.verdict;
1278
+ tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1279
+ } else {
1280
+ logger.log(` Executing review command: ${reviewDeps.commandTemplate}`);
1281
+ const result = await executeReview(
1282
+ {
1283
+ taskId,
1284
+ diffContent,
1285
+ prompt,
1286
+ owner,
1287
+ repo,
1288
+ prNumber,
1289
+ timeout: timeoutSeconds,
1290
+ reviewMode: "full"
1291
+ },
1292
+ reviewDeps
1293
+ );
1294
+ reviewText = result.review;
1295
+ verdict = result.verdict;
1296
+ tokensUsed = result.tokensUsed;
1297
+ }
1298
+ const sanitizedReview = sanitizeTokens(reviewText);
1299
+ await withRetry(
1300
+ () => client.post(`/api/tasks/${taskId}/result`, {
1301
+ agent_id: agentId,
1302
+ type: "review",
1303
+ review_text: sanitizedReview,
1304
+ verdict,
1305
+ tokens_used: tokensUsed
1306
+ }),
1307
+ { maxAttempts: 3 },
1308
+ signal
1868
1309
  );
1869
- return { entry, command };
1310
+ recordSessionUsage(consumptionDeps.session, tokensUsed);
1311
+ logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1312
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1870
1313
  }
1871
- agentCommand.command("start [agentIdOrModel]").description("Connect agent to platform via WebSocket").option("--all", "Start all agents from local config concurrently").option("-a, --anonymous", "Start an anonymous agent (no login required)").option("--model <model>", "AI model name (used with --anonymous)").option("--tool <tool>", "Review tool name (used with --anonymous)").option("--verbose", "Enable detailed WebSocket diagnostic logging").option("--router", "Router mode: relay prompts to stdout, read responses from stdin").option(
1872
- "--stability-threshold <ms>",
1873
- `Connection stability threshold in ms (${STABILITY_THRESHOLD_MIN_MS}\u2013${STABILITY_THRESHOLD_MAX_MS}, default: ${CONNECTION_STABILITY_THRESHOLD_MS})`
1874
- ).action(
1875
- async (agentIdOrModel, opts) => {
1876
- let stabilityThresholdMs;
1877
- if (opts.stabilityThreshold !== void 0) {
1878
- const val = Number(opts.stabilityThreshold);
1879
- if (!Number.isInteger(val) || val < STABILITY_THRESHOLD_MIN_MS || val > STABILITY_THRESHOLD_MAX_MS) {
1880
- console.error(
1881
- `Invalid --stability-threshold: must be an integer between ${STABILITY_THRESHOLD_MIN_MS} and ${STABILITY_THRESHOLD_MAX_MS}`
1882
- );
1883
- process.exit(1);
1884
- }
1885
- stabilityThresholdMs = val;
1886
- }
1887
- const config = loadConfig();
1888
- if (opts.anonymous) {
1889
- if (!opts.model || !opts.tool) {
1890
- console.error("Both --model and --tool are required with --anonymous.");
1891
- process.exit(1);
1892
- }
1893
- let entry;
1894
- let reviewDeps2;
1895
- let relay2;
1896
- if (opts.router) {
1897
- const existing = config.anonymousAgents.find(
1898
- (a) => a.model === opts.model && a.tool === opts.tool
1899
- );
1900
- if (existing) {
1901
- console.log(
1902
- `Reusing stored anonymous agent ${existing.agentId} (${opts.model} / ${opts.tool})`
1903
- );
1904
- entry = existing;
1905
- } else {
1906
- console.log("Registering anonymous agent...");
1907
- const client2 = new ApiClient(config.platformUrl);
1908
- const res = await client2.post("/api/agents/anonymous", {
1909
- model: opts.model,
1910
- tool: opts.tool
1911
- });
1912
- entry = {
1913
- agentId: res.agentId,
1914
- apiKey: res.apiKey,
1915
- model: opts.model,
1916
- tool: opts.tool
1917
- };
1918
- config.anonymousAgents.push(entry);
1919
- saveConfig(config);
1920
- console.log(`Agent registered: ${res.agentId} (${opts.model} / ${opts.tool})`);
1921
- }
1922
- relay2 = new RouterRelay();
1923
- relay2.start();
1924
- } else {
1925
- let resolved;
1926
- try {
1927
- resolved = await resolveAnonymousAgent(config, opts.model, opts.tool);
1928
- } catch (err) {
1929
- console.error(
1930
- "Failed to register anonymous agent:",
1931
- err instanceof Error ? err.message : err
1932
- );
1933
- process.exit(1);
1934
- }
1935
- entry = resolved.entry;
1936
- const command = resolved.command;
1937
- if (validateCommandBinary(command)) {
1938
- reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
1939
- } else {
1940
- console.warn(
1941
- `Warning: binary "${command.split(" ")[0]}" not found. Reviews will be rejected.`
1942
- );
1943
- }
1944
- }
1945
- const consumptionDeps2 = {
1946
- agentId: entry.agentId,
1947
- limits: config.limits,
1948
- session: createSessionTracker()
1949
- };
1950
- console.log(`Starting anonymous agent ${entry.agentId}...`);
1951
- startAgent(entry.agentId, config.platformUrl, entry.apiKey, reviewDeps2, consumptionDeps2, {
1952
- verbose: opts.verbose,
1953
- stabilityThresholdMs,
1954
- displayName: entry.name,
1955
- repoConfig: entry.repoConfig,
1956
- routerRelay: relay2
1314
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
1315
+ if (reviews.length === 0) {
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
1957
1327
  });
1958
- return;
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;
1959
1356
  }
1960
- if (config.agents !== null) {
1961
- const routerAgents = [];
1962
- const validAgents = [];
1963
- for (const local of config.agents) {
1964
- if (opts.router || local.router) {
1965
- routerAgents.push(local);
1966
- continue;
1967
- }
1968
- let cmd;
1969
- try {
1970
- cmd = resolveLocalAgentCommand(local, config.agentCommand);
1971
- } catch (err) {
1972
- console.warn(
1973
- `Skipping ${local.model}/${local.tool}: ${err instanceof Error ? err.message : "no command template available"}`
1974
- );
1975
- continue;
1976
- }
1977
- if (!validateCommandBinary(cmd)) {
1978
- console.warn(
1979
- `Skipping ${local.model}/${local.tool}: binary "${cmd.split(" ")[0]}" not found`
1980
- );
1981
- continue;
1982
- }
1983
- validAgents.push({ local, command: cmd });
1984
- }
1985
- const totalValid = validAgents.length + routerAgents.length;
1986
- if (totalValid === 0 && config.anonymousAgents.length === 0) {
1987
- console.error("No valid agents in config. Check that tool binaries are installed.");
1988
- process.exit(1);
1989
- }
1990
- let agentsToStart;
1991
- let routerAgentsToStart;
1992
- const anonAgentsToStart = [];
1993
- if (opts.all) {
1994
- agentsToStart = validAgents;
1995
- routerAgentsToStart = routerAgents;
1996
- anonAgentsToStart.push(...config.anonymousAgents);
1997
- } else if (agentIdOrModel) {
1998
- const cmdMatch = validAgents.find((a) => a.local.model === agentIdOrModel);
1999
- const routerMatch = routerAgents.find((a) => a.model === agentIdOrModel);
2000
- if (!cmdMatch && !routerMatch) {
2001
- console.error(`No agent with model "${agentIdOrModel}" found in local config.`);
2002
- console.error("Available agents:");
2003
- for (const a of validAgents) {
2004
- console.error(` ${a.local.model} (${a.local.tool})`);
2005
- }
2006
- for (const a of routerAgents) {
2007
- console.error(` ${a.model} (${a.tool}) [router]`);
2008
- }
2009
- process.exit(1);
2010
- }
2011
- agentsToStart = cmdMatch ? [cmdMatch] : [];
2012
- routerAgentsToStart = routerMatch ? [routerMatch] : [];
2013
- } else if (totalValid === 1) {
2014
- if (validAgents.length === 1) {
2015
- agentsToStart = [validAgents[0]];
2016
- routerAgentsToStart = [];
2017
- console.log(`Using agent ${validAgents[0].local.model} (${validAgents[0].local.tool})`);
2018
- } else {
2019
- agentsToStart = [];
2020
- routerAgentsToStart = [routerAgents[0]];
2021
- console.log(`Using router agent ${routerAgents[0].model} (${routerAgents[0].tool})`);
2022
- }
2023
- } else if (totalValid === 0) {
2024
- console.error("No valid authenticated agents in config. Use --anonymous or --all.");
2025
- process.exit(1);
2026
- } else {
2027
- console.error("Multiple agents in config. Specify a model name or use --all:");
2028
- for (const a of validAgents) {
2029
- console.error(` ${a.local.model} (${a.local.tool})`);
2030
- }
2031
- for (const a of routerAgents) {
2032
- console.error(` ${a.model} (${a.tool}) [router]`);
2033
- }
2034
- process.exit(1);
2035
- }
2036
- const totalAgents = agentsToStart.length + routerAgentsToStart.length + anonAgentsToStart.length;
2037
- if (totalAgents > 1) {
2038
- process.setMaxListeners(process.getMaxListeners() + totalAgents * 2);
2039
- }
2040
- let startedCount = 0;
2041
- let apiKey2;
2042
- let client2;
2043
- let serverAgents;
2044
- if (agentsToStart.length > 0 || routerAgentsToStart.length > 0) {
2045
- apiKey2 = requireApiKey(config);
2046
- client2 = new ApiClient(config.platformUrl, apiKey2);
2047
- try {
2048
- const res = await client2.get("/api/agents");
2049
- serverAgents = res.agents;
2050
- } catch (err) {
2051
- console.error("Failed to fetch agents:", err instanceof Error ? err.message : err);
2052
- process.exit(1);
2053
- }
2054
- }
2055
- for (const selected of agentsToStart) {
2056
- let agentId2;
2057
- try {
2058
- const sync = await syncAgentToServer(client2, serverAgents, selected.local);
2059
- agentId2 = sync.agentId;
2060
- if (sync.created) {
2061
- console.log(`Registered new agent ${agentId2} on platform`);
2062
- serverAgents.push({
2063
- id: agentId2,
2064
- model: selected.local.model,
2065
- tool: selected.local.tool,
2066
- isAnonymous: false,
2067
- status: "offline",
2068
- repoConfig: null,
2069
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
2070
- });
2071
- }
2072
- } catch (err) {
2073
- console.error(
2074
- `Failed to sync agent ${selected.local.model} to server:`,
2075
- err instanceof Error ? err.message : err
2076
- );
2077
- continue;
2078
- }
2079
- const reviewDeps2 = {
2080
- commandTemplate: selected.command,
2081
- maxDiffSizeKb: config.maxDiffSizeKb
2082
- };
2083
- const consumptionDeps2 = {
2084
- agentId: agentId2,
2085
- limits: resolveAgentLimits(selected.local.limits, config.limits),
2086
- session: createSessionTracker()
2087
- };
2088
- const label = selected.local.name || selected.local.model || "unnamed";
2089
- console.log(`Starting agent ${label} (${agentId2})...`);
2090
- startAgent(agentId2, config.platformUrl, apiKey2, reviewDeps2, consumptionDeps2, {
2091
- verbose: opts.verbose,
2092
- stabilityThresholdMs,
2093
- repoConfig: selected.local.repos,
2094
- label
2095
- });
2096
- startedCount++;
2097
- }
2098
- for (const local of routerAgentsToStart) {
2099
- let agentId2;
2100
- try {
2101
- const sync = await syncAgentToServer(client2, serverAgents, local);
2102
- agentId2 = sync.agentId;
2103
- if (sync.created) {
2104
- console.log(`Registered new agent ${agentId2} on platform`);
2105
- serverAgents.push({
2106
- id: agentId2,
2107
- model: local.model,
2108
- tool: local.tool,
2109
- isAnonymous: false,
2110
- status: "offline",
2111
- repoConfig: null,
2112
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
2113
- });
2114
- }
2115
- } catch (err) {
2116
- console.error(
2117
- `Failed to sync router agent ${local.model} to server:`,
2118
- err instanceof Error ? err.message : err
2119
- );
2120
- continue;
2121
- }
2122
- const relay2 = new RouterRelay();
2123
- relay2.start();
2124
- const consumptionDeps2 = {
2125
- agentId: agentId2,
2126
- limits: resolveAgentLimits(local.limits, config.limits),
2127
- session: createSessionTracker()
2128
- };
2129
- const label = local.name || local.model || "unnamed";
2130
- console.log(`Starting router agent ${label} (${agentId2})...`);
2131
- startAgent(agentId2, config.platformUrl, apiKey2, void 0, consumptionDeps2, {
2132
- verbose: opts.verbose,
2133
- stabilityThresholdMs,
2134
- repoConfig: local.repos,
2135
- label,
2136
- routerRelay: relay2
2137
- });
2138
- startedCount++;
2139
- }
2140
- for (const anon of anonAgentsToStart) {
2141
- let command;
2142
- try {
2143
- command = resolveCommandTemplate(
2144
- DEFAULT_REGISTRY.tools.find((t) => t.name === anon.tool)?.commandTemplate.replaceAll("${MODEL}", anon.model) ?? null
2145
- );
2146
- } catch {
2147
- console.warn(
2148
- `Skipping anonymous agent ${anon.agentId}: no command template for tool "${anon.tool}"`
2149
- );
2150
- continue;
2151
- }
2152
- let reviewDeps2;
2153
- if (validateCommandBinary(command)) {
2154
- reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
2155
- } else {
2156
- console.warn(
2157
- `Warning: binary "${command.split(" ")[0]}" not found for anonymous agent ${anon.agentId}. Reviews will be rejected.`
2158
- );
2159
- }
2160
- const consumptionDeps2 = {
2161
- agentId: anon.agentId,
2162
- limits: config.limits,
2163
- session: createSessionTracker()
2164
- };
2165
- const anonLabel = anon.name || anon.model || "anonymous";
2166
- console.log(`Starting anonymous agent ${anonLabel} (${anon.agentId})...`);
2167
- startAgent(anon.agentId, config.platformUrl, anon.apiKey, reviewDeps2, consumptionDeps2, {
2168
- verbose: opts.verbose,
2169
- stabilityThresholdMs,
2170
- displayName: anon.name,
2171
- repoConfig: anon.repoConfig,
2172
- label: anonLabel
2173
- });
2174
- startedCount++;
2175
- }
2176
- if (startedCount === 0) {
2177
- console.error("No agents could be started.");
2178
- process.exit(1);
2179
- }
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 },
1367
+ signal
1368
+ );
1369
+ recordSessionUsage(consumptionDeps.session, tokensUsed2);
1370
+ logger.log(` Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`);
1371
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1372
+ return;
1373
+ }
1374
+ const summaryReviews = reviews.map((r) => ({
1375
+ agentId: r.agent_id,
1376
+ model: "unknown",
1377
+ tool: "unknown",
1378
+ review: r.review_text,
1379
+ verdict: r.verdict
1380
+ }));
1381
+ let summaryText;
1382
+ let tokensUsed;
1383
+ if (routerRelay) {
1384
+ logger.log(` Executing summary command: [router mode]`);
1385
+ const fullPrompt = routerRelay.buildSummaryPrompt({
1386
+ owner,
1387
+ repo,
1388
+ prompt,
1389
+ reviews: summaryReviews,
1390
+ diffContent
1391
+ });
1392
+ const response = await routerRelay.sendPrompt(
1393
+ "summary_request",
1394
+ taskId,
1395
+ fullPrompt,
1396
+ timeoutSeconds
1397
+ );
1398
+ summaryText = response;
1399
+ tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1400
+ } else {
1401
+ logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
1402
+ const result = await executeSummary(
1403
+ {
1404
+ taskId,
1405
+ reviews: summaryReviews,
1406
+ prompt,
1407
+ owner,
1408
+ repo,
1409
+ prNumber,
1410
+ timeout: timeoutSeconds,
1411
+ diffContent
1412
+ },
1413
+ reviewDeps
1414
+ );
1415
+ summaryText = result.summary;
1416
+ tokensUsed = result.tokensUsed;
1417
+ }
1418
+ const sanitizedSummary = sanitizeTokens(summaryText);
1419
+ await withRetry(
1420
+ () => client.post(`/api/tasks/${taskId}/result`, {
1421
+ agent_id: agentId,
1422
+ type: "summary",
1423
+ review_text: sanitizedSummary,
1424
+ tokens_used: tokensUsed
1425
+ }),
1426
+ { maxAttempts: 3 },
1427
+ signal
1428
+ );
1429
+ recordSessionUsage(consumptionDeps.session, tokensUsed);
1430
+ logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1431
+ logger.log(formatPostReviewStats(consumptionDeps.session));
1432
+ }
1433
+ function sleep2(ms, signal) {
1434
+ return new Promise((resolve2) => {
1435
+ if (signal?.aborted) {
1436
+ resolve2();
2180
1437
  return;
2181
1438
  }
2182
- const apiKey = requireApiKey(config);
2183
- const client = new ApiClient(config.platformUrl, apiKey);
2184
- console.log(
2185
- "Hint: No agents in local config. Run `opencara agent init` to import, or `opencara agent create` to add agents."
1439
+ const timer = setTimeout(resolve2, ms);
1440
+ signal?.addEventListener(
1441
+ "abort",
1442
+ () => {
1443
+ clearTimeout(timer);
1444
+ resolve2();
1445
+ },
1446
+ { once: true }
2186
1447
  );
2187
- let agentId = agentIdOrModel;
2188
- if (!agentId) {
2189
- let res;
2190
- try {
2191
- res = await client.get("/api/agents");
2192
- } catch (err) {
2193
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
2194
- process.exit(1);
2195
- }
2196
- if (res.agents.length === 0) {
2197
- console.error("No agents registered. Run `opencara agent create` first.");
2198
- process.exit(1);
2199
- }
2200
- if (res.agents.length === 1) {
2201
- agentId = res.agents[0].id;
2202
- console.log(`Using agent ${agentId}`);
2203
- } else {
2204
- console.error("Multiple agents found. Please specify an agent ID:");
2205
- for (const a of res.agents) {
2206
- console.error(` ${a.id} ${a.model} / ${a.tool}`);
2207
- }
2208
- process.exit(1);
2209
- }
2210
- }
2211
- let reviewDeps;
2212
- let relay;
2213
- if (opts.router) {
2214
- relay = new RouterRelay();
2215
- relay.start();
1448
+ });
1449
+ }
1450
+ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
1451
+ const client = new ApiClient(platformUrl);
1452
+ const session = consumptionDeps?.session ?? createSessionTracker();
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}`);
1459
+ if (!reviewDeps) {
1460
+ logError("No review command configured. Set command in config.yml");
1461
+ return;
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)`);
2216
1468
  } else {
2217
- try {
2218
- const commandTemplate = resolveCommandTemplate(config.agentCommand);
2219
- reviewDeps = {
2220
- commandTemplate,
2221
- maxDiffSizeKb: config.maxDiffSizeKb
2222
- };
2223
- } catch (err) {
2224
- console.warn(
2225
- `Warning: ${err instanceof Error ? err.message : "Could not determine agent command."} Reviews will be rejected.`
2226
- );
2227
- }
1469
+ logWarn(`Warning: command test failed (${result.error}). Reviews may fail.`);
2228
1470
  }
2229
- const consumptionDeps = {
2230
- agentId,
2231
- limits: config.limits,
2232
- session: createSessionTracker()
2233
- };
2234
- console.log(`Starting agent ${agentId}...`);
2235
- startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
2236
- verbose: opts.verbose,
2237
- stabilityThresholdMs,
2238
- label: agentId,
2239
- routerRelay: relay
2240
- });
2241
1471
  }
2242
- );
2243
-
2244
- // src/commands/stats.ts
2245
- import { Command as Command3 } from "commander";
2246
- function formatTrustTier(tier) {
2247
- const lines = [];
2248
- const pctPositive = Math.round(tier.positiveRate * 100);
2249
- lines.push(` Trust: ${tier.label} (${tier.reviewCount} reviews, ${pctPositive}% positive)`);
2250
- if (tier.nextTier) {
2251
- const pctProgress = Math.round(tier.progressToNext * 100);
2252
- const nextLabel = tier.nextTier.charAt(0).toUpperCase() + tier.nextTier.slice(1);
2253
- lines.push(` Progress to ${nextLabel}: ${pctProgress}%`);
2254
- }
2255
- return lines.join("\n");
1472
+ const abortController = new AbortController();
1473
+ process.on("SIGINT", () => {
1474
+ log("\nShutting down...");
1475
+ abortController.abort();
1476
+ });
1477
+ process.on("SIGTERM", () => {
1478
+ abortController.abort();
1479
+ });
1480
+ await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
1481
+ pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
1482
+ maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
1483
+ routerRelay: options?.routerRelay,
1484
+ reviewOnly: options?.reviewOnly,
1485
+ repoConfig: options?.repoConfig,
1486
+ signal: abortController.signal
1487
+ });
1488
+ log("Agent stopped.");
2256
1489
  }
2257
- function formatReviewQuality(stats) {
2258
- const lines = [];
2259
- lines.push(` Reviews: ${stats.totalReviews} completed, ${stats.totalSummaries} summaries`);
2260
- const totalRatings = stats.thumbsUp + stats.thumbsDown;
2261
- if (totalRatings > 0) {
2262
- const pctPositive = Math.round(stats.thumbsUp / totalRatings * 100);
2263
- lines.push(` Quality: ${stats.thumbsUp}/${totalRatings} positive ratings (${pctPositive}%)`);
1490
+ async function startAgentRouter() {
1491
+ const config = loadConfig();
1492
+ const agentId = crypto.randomUUID();
1493
+ let commandTemplate;
1494
+ let agentConfig;
1495
+ if (config.agents && config.agents.length > 0) {
1496
+ agentConfig = config.agents.find((a) => a.router) ?? config.agents[0];
1497
+ commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
2264
1498
  } else {
2265
- lines.push(` Quality: No ratings yet`);
2266
- }
2267
- return lines.join("\n");
1499
+ commandTemplate = config.agentCommand ?? void 0;
1500
+ }
1501
+ const router = new RouterRelay();
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);
1508
+ const reviewDeps = {
1509
+ commandTemplate: commandTemplate ?? "",
1510
+ maxDiffSizeKb: config.maxDiffSizeKb,
1511
+ githubToken: auth.token,
1512
+ codebaseDir
1513
+ };
1514
+ const session = createSessionTracker();
1515
+ const model = agentConfig?.model ?? "unknown";
1516
+ const tool = agentConfig?.tool ?? "unknown";
1517
+ const label = agentConfig?.name ?? "agent[0]";
1518
+ await startAgent(
1519
+ agentId,
1520
+ config.platformUrl,
1521
+ { model, tool },
1522
+ reviewDeps,
1523
+ {
1524
+ agentId,
1525
+ session
1526
+ },
1527
+ {
1528
+ maxConsecutiveErrors: config.maxConsecutiveErrors,
1529
+ routerRelay: router,
1530
+ reviewOnly: agentConfig?.review_only,
1531
+ repoConfig: agentConfig?.repos,
1532
+ label
1533
+ }
1534
+ );
1535
+ router.stop();
2268
1536
  }
2269
- function formatRepoConfig(repoConfig) {
2270
- if (!repoConfig) return " Repos: all (default)";
2271
- switch (repoConfig.mode) {
2272
- case "all":
2273
- return " Repos: all";
2274
- case "own":
2275
- return " Repos: own repos only";
2276
- case "whitelist":
2277
- return ` Repos: whitelist (${repoConfig.list?.join(", ") ?? "none"})`;
2278
- case "blacklist":
2279
- return ` Repos: blacklist (${repoConfig.list?.join(", ") ?? "none"})`;
2280
- default:
2281
- return ` Repos: ${repoConfig.mode}`;
1537
+ function startAgentByIndex(config, agentIndex, pollIntervalMs, auth) {
1538
+ const agentId = crypto.randomUUID();
1539
+ let commandTemplate;
1540
+ let agentConfig;
1541
+ if (config.agents && config.agents.length > agentIndex) {
1542
+ agentConfig = config.agents[agentIndex];
1543
+ commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
1544
+ } else {
1545
+ commandTemplate = config.agentCommand ?? void 0;
2282
1546
  }
2283
- }
2284
- function formatAgentStats(agent, agentStats) {
2285
- const lines = [];
2286
- lines.push(`Agent: ${agent.id} (${agent.model} / ${agent.tool})`);
2287
- lines.push(formatRepoConfig(agent.repoConfig));
2288
- if (agentStats) {
2289
- lines.push(formatTrustTier(agentStats.agent.trustTier));
2290
- lines.push(formatReviewQuality(agentStats.stats));
2291
- }
2292
- return lines.join("\n");
2293
- }
2294
- async function fetchAgentStats(client, agentId) {
2295
- try {
2296
- return await client.get(`/api/stats/${agentId}`);
2297
- } catch {
1547
+ const label = agentConfig?.name ?? `agent[${agentIndex}]`;
1548
+ if (!commandTemplate) {
1549
+ console.error(`[${label}] No command configured. Skipping.`);
2298
1550
  return null;
2299
1551
  }
1552
+ if (!validateCommandBinary(commandTemplate)) {
1553
+ console.error(
1554
+ `[${label}] Command binary not found: ${commandTemplate.split(" ")[0]}. Skipping.`
1555
+ );
1556
+ return null;
1557
+ }
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;
1564
+ }
1565
+ const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
1566
+ const reviewDeps = {
1567
+ commandTemplate,
1568
+ maxDiffSizeKb: config.maxDiffSizeKb,
1569
+ githubToken,
1570
+ codebaseDir
1571
+ };
1572
+ const isRouter = agentConfig?.router === true;
1573
+ let routerRelay;
1574
+ if (isRouter) {
1575
+ routerRelay = new RouterRelay();
1576
+ routerRelay.start();
1577
+ }
1578
+ const session = createSessionTracker();
1579
+ const model = agentConfig?.model ?? "unknown";
1580
+ const tool = agentConfig?.tool ?? "unknown";
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(() => {
1596
+ routerRelay?.stop();
1597
+ });
1598
+ return agentPromise;
2300
1599
  }
2301
- var statsCommand = new Command3("stats").description("Display agent dashboard: trust tier and review quality").option("--agent <agentId>", "Show stats for a specific agent").action(async (opts) => {
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) => {
2302
1602
  const config = loadConfig();
2303
- if (opts.agent) {
2304
- const anonEntry = findAnonymousAgent(config, opts.agent);
2305
- if (anonEntry) {
2306
- const anonClient = new ApiClient(config.platformUrl, anonEntry.apiKey);
2307
- const agent = {
2308
- id: anonEntry.agentId,
2309
- model: anonEntry.model,
2310
- tool: anonEntry.tool,
2311
- isAnonymous: true,
2312
- status: "offline",
2313
- repoConfig: anonEntry.repoConfig ?? null,
2314
- createdAt: ""
2315
- };
2316
- const agentStats = await fetchAgentStats(anonClient, anonEntry.agentId);
2317
- console.log(formatAgentStats(agent, agentStats));
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);
2318
1611
  return;
2319
1612
  }
2320
- }
2321
- if (!config.apiKey && config.anonymousAgents.length > 0 && !opts.agent) {
2322
- const outputs2 = [];
2323
- for (const anon of config.anonymousAgents) {
2324
- const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
2325
- const agent = {
2326
- id: anon.agentId,
2327
- model: anon.model,
2328
- tool: anon.tool,
2329
- isAnonymous: true,
2330
- status: "offline",
2331
- repoConfig: anon.repoConfig ?? null,
2332
- createdAt: ""
2333
- };
2334
- try {
2335
- const agentStats = await fetchAgentStats(anonClient, anon.agentId);
2336
- outputs2.push(formatAgentStats(agent, agentStats));
2337
- } catch (err) {
2338
- outputs2.push(
2339
- `Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
2340
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
2341
- );
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;
2342
1622
  }
2343
1623
  }
2344
- console.log(outputs2.join("\n\n"));
2345
- return;
2346
- }
2347
- const apiKey = requireApiKey(config);
2348
- const client = new ApiClient(config.platformUrl, apiKey);
2349
- if (opts.agent) {
2350
- const agent = {
2351
- id: opts.agent,
2352
- model: "unknown",
2353
- tool: "unknown",
2354
- isAnonymous: false,
2355
- status: "offline",
2356
- repoConfig: null,
2357
- createdAt: ""
2358
- };
2359
- try {
2360
- const agentsRes2 = await client.get("/api/agents");
2361
- const found = agentsRes2.agents.find((a) => a.id === opts.agent);
2362
- if (found) {
2363
- agent.model = found.model;
2364
- agent.tool = found.tool;
2365
- }
2366
- } catch {
1624
+ if (promises.length === 0) {
1625
+ console.error("No agents could be started. Check your config.");
1626
+ process.exit(1);
1627
+ return;
2367
1628
  }
2368
- const agentStats = await fetchAgentStats(client, opts.agent);
2369
- console.log(formatAgentStats(agent, agentStats));
2370
- return;
2371
- }
2372
- let agentsRes;
2373
- try {
2374
- agentsRes = await client.get("/api/agents");
2375
- } catch (err) {
2376
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
2377
- process.exit(1);
2378
- }
2379
- if (agentsRes.agents.length === 0 && config.anonymousAgents.length === 0) {
2380
- console.log("No agents registered. Run `opencara agent create` to register one.");
2381
- return;
2382
- }
2383
- const outputs = [];
2384
- for (const agent of agentsRes.agents) {
2385
- try {
2386
- const agentStats = await fetchAgentStats(client, agent.id);
2387
- outputs.push(formatAgentStats(agent, agentStats));
2388
- } catch (err) {
2389
- outputs.push(
2390
- `Agent: ${agent.id} (${agent.model} / ${agent.tool})
2391
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
1629
+ if (startFailed) {
1630
+ console.error(
1631
+ "One or more agents could not start (see warnings above). Continuing with the rest."
2392
1632
  );
2393
1633
  }
2394
- }
2395
- for (const anon of config.anonymousAgents) {
2396
- const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
2397
- const agent = {
2398
- id: anon.agentId,
2399
- model: anon.model,
2400
- tool: anon.tool,
2401
- isAnonymous: true,
2402
- status: "offline",
2403
- repoConfig: anon.repoConfig ?? null,
2404
- createdAt: ""
2405
- };
2406
- try {
2407
- const agentStats = await fetchAgentStats(anonClient, anon.agentId);
2408
- outputs.push(formatAgentStats(agent, agentStats));
2409
- } catch (err) {
2410
- outputs.push(
2411
- `Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
2412
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
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"
2413
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;
2414
1658
  }
1659
+ await p;
2415
1660
  }
2416
- console.log(outputs.join("\n\n"));
2417
1661
  });
2418
1662
 
2419
1663
  // src/index.ts
2420
- var program = new Command4().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.9.0");
2421
- program.addCommand(loginCommand);
1664
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.11.0");
2422
1665
  program.addCommand(agentCommand);
2423
- program.addCommand(statsCommand);
2424
1666
  program.action(() => {
2425
1667
  startAgentRouter();
2426
1668
  });