opencara 0.9.0 → 0.10.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 +480 -1646
  2. package/package.json +5 -4
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
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
9
 
10
10
  // src/config.ts
11
11
  import * as fs from "fs";
@@ -15,10 +15,6 @@ import { parse, stringify } from "yaml";
15
15
  var DEFAULT_PLATFORM_URL = "https://api.opencara.dev";
16
16
  var CONFIG_DIR = path.join(os.homedir(), ".opencara");
17
17
  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
18
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
23
19
  function parseLimits(data) {
24
20
  const raw = data.limits;
@@ -74,44 +70,6 @@ function parseRepoConfig(obj, index) {
74
70
  }
75
71
  return config;
76
72
  }
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
73
  function parseAgents(data) {
116
74
  if (!("agents" in data)) return null;
117
75
  const raw = data.agents;
@@ -132,6 +90,7 @@ function parseAgents(data) {
132
90
  if (typeof obj.name === "string") agent.name = obj.name;
133
91
  if (typeof obj.command === "string") agent.command = obj.command;
134
92
  if (obj.router === true) agent.router = true;
93
+ if (obj.review_only === true) agent.review_only = true;
135
94
  const agentLimits = parseLimits(obj);
136
95
  if (agentLimits) agent.limits = agentLimits;
137
96
  const repoConfig = parseRepoConfig(obj, i);
@@ -147,8 +106,7 @@ function loadConfig() {
147
106
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
148
107
  limits: null,
149
108
  agentCommand: null,
150
- agents: null,
151
- anonymousAgents: []
109
+ agents: null
152
110
  };
153
111
  if (!fs.existsSync(CONFIG_FILE)) {
154
112
  return defaults;
@@ -164,48 +122,8 @@ function loadConfig() {
164
122
  maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
165
123
  limits: parseLimits(data),
166
124
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
167
- agents: parseAgents(data),
168
- anonymousAgents: parseAnonymousAgents(data)
169
- };
170
- }
171
- function saveConfig(config) {
172
- ensureConfigDir();
173
- const data = {
174
- platform_url: config.platformUrl
125
+ agents: parseAgents(data)
175
126
  };
176
- if (config.apiKey) {
177
- data.api_key = config.apiKey;
178
- }
179
- if (config.maxDiffSizeKb !== DEFAULT_MAX_DIFF_SIZE_KB) {
180
- data.max_diff_size_kb = config.maxDiffSizeKb;
181
- }
182
- if (config.limits) {
183
- data.limits = config.limits;
184
- }
185
- if (config.agentCommand) {
186
- data.agent_command = config.agentCommand;
187
- }
188
- if (config.agents !== null) {
189
- data.agents = config.agents;
190
- }
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;
206
- });
207
- }
208
- fs.writeFileSync(CONFIG_FILE, stringify(data), { encoding: "utf-8", mode: 384 });
209
127
  }
210
128
  function resolveAgentLimits(agentLimits, globalLimits) {
211
129
  if (!agentLimits && !globalLimits) return null;
@@ -214,19 +132,6 @@ function resolveAgentLimits(agentLimits, globalLimits) {
214
132
  const merged = { ...globalLimits, ...agentLimits };
215
133
  return Object.keys(merged).length === 0 ? null : merged;
216
134
  }
217
- function findAnonymousAgent(config, agentId) {
218
- return config.anonymousAgents.find((a) => a.agentId === agentId) ?? null;
219
- }
220
- function removeAnonymousAgent(config, agentId) {
221
- config.anonymousAgents = config.anonymousAgents.filter((a) => a.agentId !== agentId);
222
- }
223
- function requireApiKey(config) {
224
- if (!config.apiKey) {
225
- console.error("Not authenticated. Run `opencara login` first.");
226
- process.exit(1);
227
- }
228
- return config.apiKey;
229
- }
230
135
 
231
136
  // src/http.ts
232
137
  var HttpError = class extends Error {
@@ -237,9 +142,14 @@ var HttpError = class extends Error {
237
142
  }
238
143
  };
239
144
  var ApiClient = class {
240
- constructor(baseUrl, apiKey = null) {
145
+ constructor(baseUrl, apiKey = null, debug) {
241
146
  this.baseUrl = baseUrl;
242
147
  this.apiKey = apiKey;
148
+ this.debug = debug ?? process.env.OPENCARA_DEBUG === "1";
149
+ }
150
+ debug;
151
+ log(msg) {
152
+ if (this.debug) console.debug(`[ApiClient] ${msg}`);
243
153
  }
244
154
  headers() {
245
155
  const h = {
@@ -251,245 +161,78 @@ var ApiClient = class {
251
161
  return h;
252
162
  }
253
163
  async get(path3) {
164
+ this.log(`GET ${path3}`);
254
165
  const res = await fetch(`${this.baseUrl}${path3}`, {
255
166
  method: "GET",
256
167
  headers: this.headers()
257
168
  });
258
- return this.handleResponse(res);
169
+ return this.handleResponse(res, path3);
259
170
  }
260
171
  async post(path3, body) {
172
+ this.log(`POST ${path3}`);
261
173
  const res = await fetch(`${this.baseUrl}${path3}`, {
262
174
  method: "POST",
263
175
  headers: this.headers(),
264
176
  body: body !== void 0 ? JSON.stringify(body) : void 0
265
177
  });
266
- return this.handleResponse(res);
178
+ return this.handleResponse(res, path3);
267
179
  }
268
- async handleResponse(res) {
180
+ async handleResponse(res, path3) {
269
181
  if (!res.ok) {
270
- if (res.status === 401) {
271
- throw new HttpError(401, "Not authenticated. Run `opencara login` first.");
272
- }
273
182
  let message = `HTTP ${res.status}`;
274
183
  try {
275
184
  const body = await res.json();
276
185
  if (body.error) message = body.error;
277
186
  } catch {
278
187
  }
188
+ this.log(`${res.status} ${message} (${path3})`);
279
189
  throw new HttpError(res.status, message);
280
190
  }
191
+ this.log(`${res.status} OK (${path3})`);
281
192
  return await res.json();
282
193
  }
283
194
  };
284
195
 
285
- // src/reconnect.ts
286
- var DEFAULT_RECONNECT_OPTIONS = {
287
- initialDelay: 1e3,
288
- maxDelay: 3e4,
289
- multiplier: 2,
290
- jitter: true
196
+ // src/retry.ts
197
+ var DEFAULT_RETRY = {
198
+ maxAttempts: 3,
199
+ baseDelayMs: 1e3,
200
+ maxDelayMs: 3e4
291
201
  };
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;
299
- }
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;
202
+ async function withRetry(fn, options = {}, signal) {
203
+ const opts = { ...DEFAULT_RETRY, ...options };
204
+ let lastError;
205
+ for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
206
+ if (signal?.aborted) throw new Error("Aborted");
339
207
  try {
340
- tokenRes = await client.post("/auth/device/token", {
341
- deviceCode: flow.deviceCode
342
- });
208
+ return await fn();
343
209
  } 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
- }
210
+ lastError = err;
211
+ if (attempt < opts.maxAttempts - 1) {
212
+ const delay = Math.min(opts.baseDelayMs * Math.pow(2, attempt), opts.maxDelayMs);
213
+ await sleep(delay, signal);
392
214
  }
393
- return;
394
215
  }
395
216
  }
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
217
+ throw lastError;
218
+ }
219
+ function sleep(ms, signal) {
220
+ return new Promise((resolve2) => {
221
+ if (signal?.aborted) {
222
+ resolve2();
223
+ return;
487
224
  }
488
- ]
489
- };
490
-
491
- // ../shared/dist/review-config.js
492
- import { parse as parseYaml } from "yaml";
225
+ const timer = setTimeout(resolve2, ms);
226
+ signal?.addEventListener(
227
+ "abort",
228
+ () => {
229
+ clearTimeout(timer);
230
+ resolve2();
231
+ },
232
+ { once: true }
233
+ );
234
+ });
235
+ }
493
236
 
494
237
  // src/tool-executor.ts
495
238
  import { spawn, execFileSync } from "child_process";
@@ -560,14 +303,6 @@ function parseCommandTemplate(template, vars = {}) {
560
303
  }
561
304
  return { command: interpolated[0], args: interpolated.slice(1) };
562
305
  }
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
306
  var CHARS_PER_TOKEN = 4;
572
307
  function estimateTokens(text) {
573
308
  return Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -741,6 +476,7 @@ function extractVerdict(text) {
741
476
  const review = (before + after).replace(/\n{3,}/g, "\n\n").trim();
742
477
  return { verdict: verdictStr, review };
743
478
  }
479
+ console.warn("No verdict found in review output, defaulting to COMMENT");
744
480
  return { verdict: "comment", review: text };
745
481
  }
746
482
  async function executeReview(req, deps, runTool = executeTool) {
@@ -900,7 +636,7 @@ ${userMessage}`;
900
636
  }
901
637
 
902
638
  // src/router.ts
903
- import * as readline2 from "readline";
639
+ import * as readline from "readline";
904
640
  var END_OF_RESPONSE = "<<<OPENCARA_END_RESPONSE>>>";
905
641
  var RouterRelay = class {
906
642
  pending = null;
@@ -918,7 +654,7 @@ var RouterRelay = class {
918
654
  /** Start listening for stdin input */
919
655
  start() {
920
656
  this.stopped = false;
921
- this.rl = readline2.createInterface({
657
+ this.rl = readline.createInterface({
922
658
  input: this.stdin,
923
659
  terminal: false
924
660
  });
@@ -928,15 +664,16 @@ var RouterRelay = class {
928
664
  this.rl.on("close", () => {
929
665
  if (this.stopped) return;
930
666
  if (this.pending) {
931
- const response = this.responseLines.join("\n");
667
+ const response = this.responseLines.join("\n").trim();
932
668
  this.responseLines = [];
933
669
  clearTimeout(this.pending.timer);
934
670
  const task = this.pending;
935
671
  this.pending = null;
936
- if (response.trim()) {
672
+ if (response.length >= 100) {
673
+ console.warn("Router stdin closed \u2014 accepting partial response");
937
674
  task.resolve(response);
938
675
  } else {
939
- task.reject(new Error("stdin closed with no response"));
676
+ task.reject(new Error("Router process died (stdin closed with insufficient response)"));
940
677
  }
941
678
  }
942
679
  });
@@ -957,7 +694,11 @@ var RouterRelay = class {
957
694
  }
958
695
  /** Write the prompt as plain text to stdout */
959
696
  writePrompt(prompt) {
960
- this.stdout.write(prompt + "\n");
697
+ try {
698
+ this.stdout.write(prompt + "\n");
699
+ } catch (err) {
700
+ throw new Error(`Failed to write to router: ${err.message}`);
701
+ }
961
702
  }
962
703
  /** Write a status message to stderr (doesn't interfere with prompt/response on stdout/stdin) */
963
704
  writeStatus(message) {
@@ -1001,7 +742,14 @@ ${userMessage}`;
1001
742
  reject(new RouterTimeoutError(`Response timeout (${timeoutSec}s)`));
1002
743
  }, timeoutMs);
1003
744
  this.pending = { resolve: resolve2, reject, timer };
1004
- this.writePrompt(prompt);
745
+ try {
746
+ this.writePrompt(prompt);
747
+ } catch (err) {
748
+ clearTimeout(timer);
749
+ this.pending = null;
750
+ this.responseLines = [];
751
+ reject(err);
752
+ }
1005
753
  });
1006
754
  }
1007
755
  /** Parse a review response: extract verdict and review text */
@@ -1016,11 +764,15 @@ ${userMessage}`;
1016
764
  handleLine(line) {
1017
765
  if (!this.pending) return;
1018
766
  if (line.trim() === END_OF_RESPONSE) {
1019
- const response = this.responseLines.join("\n");
767
+ const response = this.responseLines.join("\n").trim();
1020
768
  this.responseLines = [];
1021
769
  clearTimeout(this.pending.timer);
1022
770
  const task = this.pending;
1023
771
  this.pending = null;
772
+ if (!response || response.length < 10) {
773
+ task.reject(new Error("Router returned empty or trivially short response"));
774
+ return;
775
+ }
1024
776
  task.resolve(response);
1025
777
  return;
1026
778
  }
@@ -1035,9 +787,6 @@ var RouterTimeoutError = class extends Error {
1035
787
  };
1036
788
 
1037
789
  // src/consumption.ts
1038
- async function checkConsumptionLimits(_agentId, _limits) {
1039
- return { allowed: true };
1040
- }
1041
790
  function createSessionTracker() {
1042
791
  return { tokens: 0, reviews: 0 };
1043
792
  }
@@ -1045,1382 +794,467 @@ function recordSessionUsage(session, tokensUsed) {
1045
794
  session.tokens += tokensUsed;
1046
795
  session.reviews += 1;
1047
796
  }
1048
- function formatPostReviewStats(_tokensUsed, session, _limits, _dailyStats) {
797
+ function formatPostReviewStats(session) {
1049
798
  return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
1050
799
  }
1051
800
 
1052
801
  // 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
- }
1082
- }
1083
- function buildWsUrl(platformUrl, agentId, apiKey) {
1084
- return platformUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + `/ws/agent/${agentId}?token=${encodeURIComponent(apiKey)}`;
1085
- }
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 {
1157
- }
1158
- }, WS_PING_INTERVAL_MS);
1159
- if (verbose) {
1160
- log(`[verbose] Connection opened at ${new Date(connectionOpenedAt).toISOString()}`);
802
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
803
+ var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
804
+ var MAX_POLL_BACKOFF_MS = 3e5;
805
+ async function fetchDiff(diffUrl, signal) {
806
+ const patchUrl = diffUrl.endsWith(".diff") ? diffUrl : `${diffUrl}.diff`;
807
+ return withRetry(
808
+ async () => {
809
+ const response = await fetch(patchUrl);
810
+ if (!response.ok) {
811
+ throw new Error(`Failed to fetch diff: ${response.status} ${response.statusText}`);
1161
812
  }
1162
- clearStabilityTimer();
1163
- stabilityTimer = setTimeout(() => {
1164
- if (verbose) {
1165
- log(
1166
- `[verbose] Connection stable for ${stabilityThreshold / 1e3}s \u2014 resetting reconnect counter`
1167
- );
1168
- }
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()}`);
1217
- }
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)}`
813
+ return response.text();
814
+ },
815
+ { maxAttempts: 2 },
816
+ signal
1267
817
  );
1268
818
  }
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()}`
819
+ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, options) {
820
+ const { pollIntervalMs, routerRelay, reviewOnly, signal } = options;
821
+ console.log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
822
+ let consecutiveAuthErrors = 0;
823
+ let consecutiveErrors = 0;
824
+ while (!signal?.aborted) {
825
+ try {
826
+ const pollBody = { agent_id: agentId };
827
+ if (reviewOnly) pollBody.review_only = true;
828
+ const pollResponse = await client.post("/api/tasks/poll", pollBody);
829
+ consecutiveAuthErrors = 0;
830
+ consecutiveErrors = 0;
831
+ if (pollResponse.tasks.length > 0) {
832
+ const task = pollResponse.tasks[0];
833
+ await handleTask(
834
+ client,
835
+ agentId,
836
+ task,
837
+ reviewDeps,
838
+ consumptionDeps,
839
+ agentInfo,
840
+ routerRelay,
841
+ signal
1290
842
  );
1291
843
  }
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}`
1298
- );
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
- })
844
+ } catch (err) {
845
+ if (signal?.aborted) break;
846
+ if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
847
+ consecutiveAuthErrors++;
848
+ consecutiveErrors++;
849
+ console.error(
850
+ `Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
1383
851
  );
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;
1402
- }
852
+ if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
853
+ console.error("Authentication failed repeatedly. Exiting.");
854
+ break;
1403
855
  }
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);
1454
- }
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;
1535
- }
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
- });
1544
- break;
856
+ } else {
857
+ consecutiveAuthErrors = 0;
858
+ consecutiveErrors++;
859
+ console.error(`Poll error: ${err.message}`);
1545
860
  }
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
861
+ if (consecutiveErrors > 0) {
862
+ const backoff = Math.min(
863
+ pollIntervalMs * Math.pow(2, consecutiveErrors - 1),
864
+ MAX_POLL_BACKOFF_MS
865
+ );
866
+ const extraDelay = backoff - pollIntervalMs;
867
+ if (extraDelay > 0) {
868
+ console.warn(
869
+ `Poll failed (${consecutiveErrors} consecutive). Next poll in ${Math.round(backoff / 1e3)}s`
1593
870
  );
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);
871
+ await sleep2(extraDelay, signal);
1613
872
  }
1614
- })();
1615
- break;
1616
- }
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;
1623
- }
1624
- }
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 };
1631
- }
1632
- const body = { model: localAgent.model, tool: localAgent.tool };
1633
- if (localAgent.name) {
1634
- body.displayName = localAgent.name;
1635
- }
1636
- if (localAgent.repos) {
1637
- body.repoConfig = localAgent.repos;
1638
- }
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;
1668
- 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;
1673
- }
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
- }));
1685
- 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
873
  }
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
- });
1737
- } catch (err) {
1738
- if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
1739
- console.log("Cancelled.");
1740
- return;
1741
- }
1742
- throw err;
1743
874
  }
875
+ await sleep2(pollIntervalMs, signal);
1744
876
  }
1745
- if (!command) {
1746
- const toolEntry = DEFAULT_REGISTRY.tools.find((t) => t.name === tool);
1747
- if (toolEntry) {
1748
- command = toolEntry.commandTemplate.replaceAll("${MODEL}", model);
1749
- } else {
1750
- console.error(`No command template for tool "${tool}". Use --command to specify one.`);
1751
- process.exit(1);
1752
- }
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.`
877
+ }
878
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, routerRelay, signal) {
879
+ const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
880
+ console.log(`
881
+ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
882
+ let claimResponse;
883
+ try {
884
+ claimResponse = await withRetry(
885
+ () => client.post(`/api/tasks/${task_id}/claim`, {
886
+ agent_id: agentId,
887
+ role,
888
+ model: agentInfo.model,
889
+ tool: agentInfo.tool
890
+ }),
891
+ { maxAttempts: 2 },
892
+ signal
1759
893
  );
894
+ } catch (err) {
895
+ const status = err instanceof HttpError ? ` (${err.status})` : "";
896
+ console.error(` Failed to claim task ${task_id}${status}: ${err.message}`);
897
+ return;
1760
898
  }
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);
899
+ if (!claimResponse.claimed) {
900
+ console.log(` Claim rejected: ${claimResponse.reason}`);
901
+ return;
1769
902
  }
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;
903
+ console.log(` Claimed as ${role}`);
904
+ let diffContent;
1782
905
  try {
1783
- res = await client.get("/api/agents");
906
+ diffContent = await fetchDiff(diff_url, signal);
907
+ console.log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
1784
908
  } 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.");
909
+ console.error(` Failed to fetch diff for task ${task_id}: ${err.message}`);
910
+ await safeReject(client, task_id, agentId, `Cannot access diff: ${err.message}`);
1790
911
  return;
1791
912
  }
1792
- let registry;
1793
913
  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);
914
+ if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
915
+ await executeSummaryTask(
916
+ client,
917
+ agentId,
918
+ task_id,
919
+ owner,
920
+ repo,
921
+ pr_number,
922
+ diffContent,
923
+ prompt,
924
+ timeout_seconds,
925
+ claimResponse.reviews,
926
+ reviewDeps,
927
+ consumptionDeps,
928
+ routerRelay,
929
+ signal
930
+ );
1807
931
  } else {
1808
- console.warn(
1809
- `Warning: no command template for ${agent.model}/${agent.tool} \u2014 set command manually in config`
932
+ await executeReviewTask(
933
+ client,
934
+ agentId,
935
+ task_id,
936
+ owner,
937
+ repo,
938
+ pr_number,
939
+ diffContent,
940
+ prompt,
941
+ timeout_seconds,
942
+ reviewDeps,
943
+ consumptionDeps,
944
+ routerRelay,
945
+ signal
1810
946
  );
1811
947
  }
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
- }
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;
1827
- try {
1828
- res = await client.get("/api/agents");
1829
948
  } catch (err) {
1830
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1831
- process.exit(1);
1832
- }
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 {
949
+ if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
950
+ console.error(` ${err.message}`);
951
+ await safeReject(client, task_id, agentId, err.message);
952
+ } else {
953
+ console.error(` Error on task ${task_id}: ${err.message}`);
954
+ await safeError(client, task_id, agentId, err.message);
1839
955
  }
1840
956
  }
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
957
+ }
958
+ async function safeReject(client, taskId, agentId, reason) {
959
+ try {
960
+ await withRetry(
961
+ () => client.post(`/api/tasks/${taskId}/reject`, { agent_id: agentId, reason }),
962
+ { maxAttempts: 2 }
963
+ );
964
+ } catch (err) {
965
+ console.error(
966
+ ` Failed to report rejection for task ${taskId}: ${err.message} (logged locally)`
1849
967
  );
1850
- return { entry: existing, command: command2 };
1851
968
  }
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
1868
- );
1869
- return { entry, command };
1870
969
  }
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
1957
- });
1958
- return;
1959
- }
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
- }
2180
- return;
2181
- }
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."
2186
- );
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();
2216
- } 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
- }
2228
- }
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
970
+ async function safeError(client, taskId, agentId, error) {
971
+ try {
972
+ await withRetry(() => client.post(`/api/tasks/${taskId}/error`, { agent_id: agentId, error }), {
973
+ maxAttempts: 2
2240
974
  });
975
+ } catch (err) {
976
+ console.error(
977
+ ` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
978
+ );
2241
979
  }
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");
2256
980
  }
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}%)`);
981
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, routerRelay, signal) {
982
+ let reviewText;
983
+ let verdict;
984
+ let tokensUsed;
985
+ if (routerRelay) {
986
+ const fullPrompt = routerRelay.buildReviewPrompt({
987
+ owner,
988
+ repo,
989
+ reviewMode: "full",
990
+ prompt,
991
+ diffContent
992
+ });
993
+ const response = await routerRelay.sendPrompt(
994
+ "review_request",
995
+ taskId,
996
+ fullPrompt,
997
+ timeoutSeconds
998
+ );
999
+ const parsed = routerRelay.parseReviewResponse(response);
1000
+ reviewText = parsed.review;
1001
+ verdict = parsed.verdict;
1002
+ tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
2264
1003
  } else {
2265
- lines.push(` Quality: No ratings yet`);
2266
- }
2267
- return lines.join("\n");
1004
+ const result = await executeReview(
1005
+ {
1006
+ taskId,
1007
+ diffContent,
1008
+ prompt,
1009
+ owner,
1010
+ repo,
1011
+ prNumber,
1012
+ timeout: timeoutSeconds,
1013
+ reviewMode: "full"
1014
+ },
1015
+ reviewDeps
1016
+ );
1017
+ reviewText = result.review;
1018
+ verdict = result.verdict;
1019
+ tokensUsed = result.tokensUsed;
1020
+ }
1021
+ await withRetry(
1022
+ () => client.post(`/api/tasks/${taskId}/result`, {
1023
+ agent_id: agentId,
1024
+ type: "review",
1025
+ review_text: reviewText,
1026
+ verdict,
1027
+ tokens_used: tokensUsed
1028
+ }),
1029
+ { maxAttempts: 3 },
1030
+ signal
1031
+ );
1032
+ recordSessionUsage(consumptionDeps.session, tokensUsed);
1033
+ console.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1034
+ console.log(formatPostReviewStats(consumptionDeps.session));
2268
1035
  }
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}`;
1036
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, routerRelay, signal) {
1037
+ if (reviews.length === 0) {
1038
+ return executeReviewTask(
1039
+ client,
1040
+ agentId,
1041
+ taskId,
1042
+ owner,
1043
+ repo,
1044
+ prNumber,
1045
+ diffContent,
1046
+ prompt,
1047
+ timeoutSeconds,
1048
+ reviewDeps,
1049
+ consumptionDeps,
1050
+ routerRelay,
1051
+ signal
1052
+ );
2282
1053
  }
1054
+ const summaryReviews = reviews.map((r) => ({
1055
+ agentId: r.agent_id,
1056
+ model: "unknown",
1057
+ tool: "unknown",
1058
+ review: r.review_text,
1059
+ verdict: r.verdict
1060
+ }));
1061
+ let summaryText;
1062
+ let tokensUsed;
1063
+ if (routerRelay) {
1064
+ const fullPrompt = routerRelay.buildSummaryPrompt({
1065
+ owner,
1066
+ repo,
1067
+ prompt,
1068
+ reviews: summaryReviews,
1069
+ diffContent
1070
+ });
1071
+ const response = await routerRelay.sendPrompt(
1072
+ "summary_request",
1073
+ taskId,
1074
+ fullPrompt,
1075
+ timeoutSeconds
1076
+ );
1077
+ summaryText = response;
1078
+ tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1079
+ } else {
1080
+ const result = await executeSummary(
1081
+ {
1082
+ taskId,
1083
+ reviews: summaryReviews,
1084
+ prompt,
1085
+ owner,
1086
+ repo,
1087
+ prNumber,
1088
+ timeout: timeoutSeconds,
1089
+ diffContent
1090
+ },
1091
+ reviewDeps
1092
+ );
1093
+ summaryText = result.summary;
1094
+ tokensUsed = result.tokensUsed;
1095
+ }
1096
+ await withRetry(
1097
+ () => client.post(`/api/tasks/${taskId}/result`, {
1098
+ agent_id: agentId,
1099
+ type: "summary",
1100
+ review_text: summaryText,
1101
+ tokens_used: tokensUsed
1102
+ }),
1103
+ { maxAttempts: 3 },
1104
+ signal
1105
+ );
1106
+ recordSessionUsage(consumptionDeps.session, tokensUsed);
1107
+ console.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1108
+ console.log(formatPostReviewStats(consumptionDeps.session));
2283
1109
  }
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");
1110
+ function sleep2(ms, signal) {
1111
+ return new Promise((resolve2) => {
1112
+ if (signal?.aborted) {
1113
+ resolve2();
1114
+ return;
1115
+ }
1116
+ const timer = setTimeout(resolve2, ms);
1117
+ signal?.addEventListener(
1118
+ "abort",
1119
+ () => {
1120
+ clearTimeout(timer);
1121
+ resolve2();
1122
+ },
1123
+ { once: true }
1124
+ );
1125
+ });
2293
1126
  }
2294
- async function fetchAgentStats(client, agentId) {
2295
- try {
2296
- return await client.get(`/api/stats/${agentId}`);
2297
- } catch {
2298
- return null;
1127
+ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
1128
+ const client = new ApiClient(platformUrl);
1129
+ const session = consumptionDeps?.session ?? createSessionTracker();
1130
+ const deps = consumptionDeps ?? { agentId, limits: null, session };
1131
+ console.log(`Agent ${agentId} starting...`);
1132
+ console.log(`Platform: ${platformUrl}`);
1133
+ console.log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
1134
+ if (!reviewDeps) {
1135
+ console.error("No review command configured. Set command in config.yml");
1136
+ return;
2299
1137
  }
1138
+ const abortController = new AbortController();
1139
+ process.on("SIGINT", () => {
1140
+ console.log("\nShutting down...");
1141
+ abortController.abort();
1142
+ });
1143
+ process.on("SIGTERM", () => {
1144
+ abortController.abort();
1145
+ });
1146
+ await pollLoop(client, agentId, reviewDeps, deps, agentInfo, {
1147
+ pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
1148
+ routerRelay: options?.routerRelay,
1149
+ reviewOnly: options?.reviewOnly,
1150
+ signal: abortController.signal
1151
+ });
1152
+ console.log("Agent stopped.");
2300
1153
  }
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) => {
1154
+ async function startAgentRouter() {
2302
1155
  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));
2318
- return;
2319
- }
1156
+ const agentId = crypto.randomUUID();
1157
+ let commandTemplate;
1158
+ let agentConfig;
1159
+ if (config.agents && config.agents.length > 0) {
1160
+ agentConfig = config.agents.find((a) => a.router) ?? config.agents[0];
1161
+ commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
1162
+ } else {
1163
+ commandTemplate = config.agentCommand ?? void 0;
2320
1164
  }
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
- );
2342
- }
1165
+ const router = new RouterRelay();
1166
+ router.start();
1167
+ const reviewDeps = {
1168
+ commandTemplate: commandTemplate ?? "",
1169
+ maxDiffSizeKb: config.maxDiffSizeKb
1170
+ };
1171
+ const session = createSessionTracker();
1172
+ const limits = agentConfig ? resolveAgentLimits(agentConfig.limits, config.limits) : config.limits;
1173
+ const model = agentConfig?.model ?? "unknown";
1174
+ const tool = agentConfig?.tool ?? "unknown";
1175
+ await startAgent(
1176
+ agentId,
1177
+ config.platformUrl,
1178
+ { model, tool },
1179
+ reviewDeps,
1180
+ {
1181
+ agentId,
1182
+ limits,
1183
+ session
1184
+ },
1185
+ {
1186
+ routerRelay: router,
1187
+ reviewOnly: agentConfig?.review_only
2343
1188
  }
2344
- console.log(outputs2.join("\n\n"));
2345
- return;
1189
+ );
1190
+ router.stop();
1191
+ }
1192
+ var agentCommand = new Command("agent").description("Manage review agents");
1193
+ agentCommand.command("start").description("Start an agent in polling mode").option("--poll-interval <seconds>", "Poll interval in seconds", "10").option("--agent <index>", "Agent index from config.yml (0-based)", "0").action(async (opts) => {
1194
+ const config = loadConfig();
1195
+ const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
1196
+ const agentIndex = parseInt(opts.agent, 10);
1197
+ const agentId = crypto.randomUUID();
1198
+ let commandTemplate;
1199
+ let limits = config.limits;
1200
+ let agentConfig;
1201
+ if (config.agents && config.agents.length > agentIndex) {
1202
+ agentConfig = config.agents[agentIndex];
1203
+ commandTemplate = agentConfig.command ?? config.agentCommand ?? void 0;
1204
+ limits = resolveAgentLimits(agentConfig.limits, config.limits);
1205
+ } else {
1206
+ commandTemplate = config.agentCommand ?? void 0;
2346
1207
  }
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 {
2367
- }
2368
- const agentStats = await fetchAgentStats(client, opts.agent);
2369
- console.log(formatAgentStats(agent, agentStats));
1208
+ if (!commandTemplate) {
1209
+ console.error(
1210
+ "No command configured. Set agent_command or agents[].command in ~/.opencara/config.yml"
1211
+ );
1212
+ process.exit(1);
2370
1213
  return;
2371
1214
  }
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);
1215
+ if (!validateCommandBinary(commandTemplate)) {
1216
+ console.error(`Command binary not found: ${commandTemplate.split(" ")[0]}`);
2377
1217
  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
1218
  return;
2382
1219
  }
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"}`
2392
- );
2393
- }
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"}`
2413
- );
2414
- }
1220
+ const reviewDeps = {
1221
+ commandTemplate,
1222
+ maxDiffSizeKb: config.maxDiffSizeKb
1223
+ };
1224
+ const isRouter = agentConfig?.router === true;
1225
+ let routerRelay;
1226
+ if (isRouter) {
1227
+ routerRelay = new RouterRelay();
1228
+ routerRelay.start();
1229
+ }
1230
+ const session = createSessionTracker();
1231
+ const model = agentConfig?.model ?? "unknown";
1232
+ const tool = agentConfig?.tool ?? "unknown";
1233
+ try {
1234
+ await startAgent(
1235
+ agentId,
1236
+ config.platformUrl,
1237
+ { model, tool },
1238
+ reviewDeps,
1239
+ {
1240
+ agentId,
1241
+ limits,
1242
+ session
1243
+ },
1244
+ {
1245
+ pollIntervalMs,
1246
+ routerRelay,
1247
+ reviewOnly: agentConfig?.review_only
1248
+ }
1249
+ );
1250
+ } finally {
1251
+ routerRelay?.stop();
2415
1252
  }
2416
- console.log(outputs.join("\n\n"));
2417
1253
  });
2418
1254
 
2419
1255
  // 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);
1256
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.10.0");
2422
1257
  program.addCommand(agentCommand);
2423
- program.addCommand(statsCommand);
2424
1258
  program.action(() => {
2425
1259
  startAgentRouter();
2426
1260
  });