opencara 0.8.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 +607 -1369
  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;
@@ -131,6 +89,8 @@ function parseAgents(data) {
131
89
  const agent = { model: obj.model, tool: obj.tool };
132
90
  if (typeof obj.name === "string") agent.name = obj.name;
133
91
  if (typeof obj.command === "string") agent.command = obj.command;
92
+ if (obj.router === true) agent.router = true;
93
+ if (obj.review_only === true) agent.review_only = true;
134
94
  const agentLimits = parseLimits(obj);
135
95
  if (agentLimits) agent.limits = agentLimits;
136
96
  const repoConfig = parseRepoConfig(obj, i);
@@ -146,8 +106,7 @@ function loadConfig() {
146
106
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
147
107
  limits: null,
148
108
  agentCommand: null,
149
- agents: null,
150
- anonymousAgents: []
109
+ agents: null
151
110
  };
152
111
  if (!fs.existsSync(CONFIG_FILE)) {
153
112
  return defaults;
@@ -163,48 +122,8 @@ function loadConfig() {
163
122
  maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
164
123
  limits: parseLimits(data),
165
124
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
166
- agents: parseAgents(data),
167
- anonymousAgents: parseAnonymousAgents(data)
168
- };
169
- }
170
- function saveConfig(config) {
171
- ensureConfigDir();
172
- const data = {
173
- platform_url: config.platformUrl
125
+ agents: parseAgents(data)
174
126
  };
175
- if (config.apiKey) {
176
- data.api_key = config.apiKey;
177
- }
178
- if (config.maxDiffSizeKb !== DEFAULT_MAX_DIFF_SIZE_KB) {
179
- data.max_diff_size_kb = config.maxDiffSizeKb;
180
- }
181
- if (config.limits) {
182
- data.limits = config.limits;
183
- }
184
- if (config.agentCommand) {
185
- data.agent_command = config.agentCommand;
186
- }
187
- if (config.agents !== null) {
188
- data.agents = config.agents;
189
- }
190
- if (config.anonymousAgents.length > 0) {
191
- data.anonymous_agents = config.anonymousAgents.map((a) => {
192
- const entry = {
193
- agent_id: a.agentId,
194
- api_key: a.apiKey,
195
- model: a.model,
196
- tool: a.tool
197
- };
198
- if (a.name) {
199
- entry.name = a.name;
200
- }
201
- if (a.repoConfig) {
202
- entry.repo_config = a.repoConfig;
203
- }
204
- return entry;
205
- });
206
- }
207
- fs.writeFileSync(CONFIG_FILE, stringify(data), { encoding: "utf-8", mode: 384 });
208
127
  }
209
128
  function resolveAgentLimits(agentLimits, globalLimits) {
210
129
  if (!agentLimits && !globalLimits) return null;
@@ -213,19 +132,6 @@ function resolveAgentLimits(agentLimits, globalLimits) {
213
132
  const merged = { ...globalLimits, ...agentLimits };
214
133
  return Object.keys(merged).length === 0 ? null : merged;
215
134
  }
216
- function findAnonymousAgent(config, agentId) {
217
- return config.anonymousAgents.find((a) => a.agentId === agentId) ?? null;
218
- }
219
- function removeAnonymousAgent(config, agentId) {
220
- config.anonymousAgents = config.anonymousAgents.filter((a) => a.agentId !== agentId);
221
- }
222
- function requireApiKey(config) {
223
- if (!config.apiKey) {
224
- console.error("Not authenticated. Run `opencara login` first.");
225
- process.exit(1);
226
- }
227
- return config.apiKey;
228
- }
229
135
 
230
136
  // src/http.ts
231
137
  var HttpError = class extends Error {
@@ -236,9 +142,14 @@ var HttpError = class extends Error {
236
142
  }
237
143
  };
238
144
  var ApiClient = class {
239
- constructor(baseUrl, apiKey = null) {
145
+ constructor(baseUrl, apiKey = null, debug) {
240
146
  this.baseUrl = baseUrl;
241
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}`);
242
153
  }
243
154
  headers() {
244
155
  const h = {
@@ -250,245 +161,78 @@ var ApiClient = class {
250
161
  return h;
251
162
  }
252
163
  async get(path3) {
164
+ this.log(`GET ${path3}`);
253
165
  const res = await fetch(`${this.baseUrl}${path3}`, {
254
166
  method: "GET",
255
167
  headers: this.headers()
256
168
  });
257
- return this.handleResponse(res);
169
+ return this.handleResponse(res, path3);
258
170
  }
259
171
  async post(path3, body) {
172
+ this.log(`POST ${path3}`);
260
173
  const res = await fetch(`${this.baseUrl}${path3}`, {
261
174
  method: "POST",
262
175
  headers: this.headers(),
263
176
  body: body !== void 0 ? JSON.stringify(body) : void 0
264
177
  });
265
- return this.handleResponse(res);
178
+ return this.handleResponse(res, path3);
266
179
  }
267
- async handleResponse(res) {
180
+ async handleResponse(res, path3) {
268
181
  if (!res.ok) {
269
- if (res.status === 401) {
270
- throw new HttpError(401, "Not authenticated. Run `opencara login` first.");
271
- }
272
182
  let message = `HTTP ${res.status}`;
273
183
  try {
274
184
  const body = await res.json();
275
185
  if (body.error) message = body.error;
276
186
  } catch {
277
187
  }
188
+ this.log(`${res.status} ${message} (${path3})`);
278
189
  throw new HttpError(res.status, message);
279
190
  }
191
+ this.log(`${res.status} OK (${path3})`);
280
192
  return await res.json();
281
193
  }
282
194
  };
283
195
 
284
- // src/reconnect.ts
285
- var DEFAULT_RECONNECT_OPTIONS = {
286
- initialDelay: 1e3,
287
- maxDelay: 3e4,
288
- multiplier: 2,
289
- jitter: true
196
+ // src/retry.ts
197
+ var DEFAULT_RETRY = {
198
+ maxAttempts: 3,
199
+ baseDelayMs: 1e3,
200
+ maxDelayMs: 3e4
290
201
  };
291
- function calculateDelay(attempt, options = DEFAULT_RECONNECT_OPTIONS) {
292
- const base = Math.min(
293
- options.initialDelay * Math.pow(options.multiplier, attempt),
294
- options.maxDelay
295
- );
296
- if (options.jitter) {
297
- return base + Math.random() * 500;
298
- }
299
- return base;
300
- }
301
- function sleep(ms) {
302
- return new Promise((resolve2) => setTimeout(resolve2, ms));
303
- }
304
-
305
- // src/commands/login.ts
306
- function promptYesNo(question) {
307
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
308
- return new Promise((resolve2) => {
309
- rl.question(question, (answer) => {
310
- rl.close();
311
- const normalized = answer.trim().toLowerCase();
312
- resolve2(normalized === "" || normalized === "y" || normalized === "yes");
313
- });
314
- });
315
- }
316
- var loginCommand = new Command("login").description("Authenticate with GitHub via device flow").action(async () => {
317
- const config = loadConfig();
318
- const client = new ApiClient(config.platformUrl);
319
- let flow;
320
- try {
321
- flow = await client.post("/auth/device");
322
- } catch (err) {
323
- console.error("Failed to start device flow:", err instanceof Error ? err.message : err);
324
- process.exit(1);
325
- }
326
- console.log();
327
- console.log("To sign in, open this URL in your browser:");
328
- console.log(` ${flow.verificationUri}`);
329
- console.log();
330
- console.log(`And enter this code: ${flow.userCode}`);
331
- console.log();
332
- console.log("Waiting for authorization...");
333
- const intervalMs = flow.interval * 1e3;
334
- const deadline = Date.now() + flow.expiresIn * 1e3;
335
- while (Date.now() < deadline) {
336
- await sleep(intervalMs);
337
- 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");
338
207
  try {
339
- tokenRes = await client.post("/auth/device/token", {
340
- deviceCode: flow.deviceCode
341
- });
208
+ return await fn();
342
209
  } catch (err) {
343
- console.error("Polling error:", err instanceof Error ? err.message : err);
344
- continue;
345
- }
346
- if (tokenRes.status === "pending") {
347
- process.stdout.write(".");
348
- continue;
349
- }
350
- if (tokenRes.status === "expired") {
351
- console.error("\nDevice code expired. Please run `opencara login` again.");
352
- process.exit(1);
353
- }
354
- if (tokenRes.status === "complete") {
355
- config.apiKey = tokenRes.apiKey;
356
- saveConfig(config);
357
- console.log("\nLogged in successfully. API key saved to ~/.opencara/config.yml");
358
- if (config.anonymousAgents.length > 0 && process.stdin.isTTY) {
359
- console.log();
360
- console.log(`Found ${config.anonymousAgents.length} anonymous agent(s):`);
361
- for (const anon of config.anonymousAgents) {
362
- console.log(` - ${anon.agentId} (${anon.model} / ${anon.tool})`);
363
- }
364
- const shouldLink = await promptYesNo("Link to your GitHub account? [Y/n] ");
365
- if (shouldLink) {
366
- const authedClient = new ApiClient(config.platformUrl, tokenRes.apiKey);
367
- let linkedCount = 0;
368
- const toRemove = [];
369
- for (const anon of config.anonymousAgents) {
370
- try {
371
- await authedClient.post("/api/account/link", {
372
- anonymousApiKey: anon.apiKey
373
- });
374
- toRemove.push(anon.agentId);
375
- linkedCount++;
376
- } catch (err) {
377
- console.error(
378
- `Failed to link agent ${anon.agentId}:`,
379
- err instanceof Error ? err.message : err
380
- );
381
- }
382
- }
383
- for (const id of toRemove) {
384
- removeAnonymousAgent(config, id);
385
- }
386
- saveConfig(config);
387
- if (linkedCount > 0) {
388
- console.log(`Linked ${linkedCount} agent(s) to your account.`);
389
- }
390
- }
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);
391
214
  }
392
- return;
393
215
  }
394
216
  }
395
- console.error("\nDevice code expired. Please run `opencara login` again.");
396
- process.exit(1);
397
- });
398
-
399
- // src/commands/agent.ts
400
- import { Command as Command2 } from "commander";
401
- import WebSocket from "ws";
402
- import crypto2 from "crypto";
403
-
404
- // ../shared/dist/api.js
405
- var DEFAULT_REGISTRY = {
406
- tools: [
407
- {
408
- name: "claude",
409
- displayName: "Claude",
410
- binary: "claude",
411
- commandTemplate: "claude --model ${MODEL} -p ${PROMPT} --output-format text",
412
- tokenParser: "claude"
413
- },
414
- {
415
- name: "codex",
416
- displayName: "Codex",
417
- binary: "codex",
418
- commandTemplate: "codex --model ${MODEL} -p ${PROMPT}",
419
- tokenParser: "codex"
420
- },
421
- {
422
- name: "gemini",
423
- displayName: "Gemini",
424
- binary: "gemini",
425
- commandTemplate: "gemini --model ${MODEL} -p ${PROMPT}",
426
- tokenParser: "gemini"
427
- },
428
- {
429
- name: "qwen",
430
- displayName: "Qwen",
431
- binary: "qwen",
432
- commandTemplate: "qwen --model ${MODEL} -p ${PROMPT} -y",
433
- tokenParser: "qwen"
434
- }
435
- ],
436
- models: [
437
- {
438
- name: "claude-opus-4-6",
439
- displayName: "Claude Opus 4.6",
440
- tools: ["claude"],
441
- defaultReputation: 0.8
442
- },
443
- {
444
- name: "claude-opus-4-6[1m]",
445
- displayName: "Claude Opus 4.6 (1M context)",
446
- tools: ["claude"],
447
- defaultReputation: 0.8
448
- },
449
- {
450
- name: "claude-sonnet-4-6",
451
- displayName: "Claude Sonnet 4.6",
452
- tools: ["claude"],
453
- defaultReputation: 0.7
454
- },
455
- {
456
- name: "claude-sonnet-4-6[1m]",
457
- displayName: "Claude Sonnet 4.6 (1M context)",
458
- tools: ["claude"],
459
- defaultReputation: 0.7
460
- },
461
- {
462
- name: "gpt-5-codex",
463
- displayName: "GPT-5 Codex",
464
- tools: ["codex"],
465
- defaultReputation: 0.7
466
- },
467
- {
468
- name: "gemini-2.5-pro",
469
- displayName: "Gemini 2.5 Pro",
470
- tools: ["gemini"],
471
- defaultReputation: 0.7
472
- },
473
- {
474
- name: "qwen3.5-plus",
475
- displayName: "Qwen 3.5 Plus",
476
- tools: ["qwen"],
477
- defaultReputation: 0.6
478
- },
479
- { name: "glm-5", displayName: "GLM-5", tools: ["qwen"], defaultReputation: 0.5 },
480
- { name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"], defaultReputation: 0.5 },
481
- {
482
- name: "minimax-m2.5",
483
- displayName: "Minimax M2.5",
484
- tools: ["qwen"],
485
- 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;
486
224
  }
487
- ]
488
- };
489
-
490
- // ../shared/dist/review-config.js
491
- 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
+ }
492
236
 
493
237
  // src/tool-executor.ts
494
238
  import { spawn, execFileSync } from "child_process";
@@ -559,14 +303,6 @@ function parseCommandTemplate(template, vars = {}) {
559
303
  }
560
304
  return { command: interpolated[0], args: interpolated.slice(1) };
561
305
  }
562
- function resolveCommandTemplate(agentCommand2) {
563
- if (agentCommand2) {
564
- return agentCommand2;
565
- }
566
- throw new Error(
567
- "No command configured for this agent. Set command in ~/.opencara/config.yml agents section or run `opencara agent create`."
568
- );
569
- }
570
306
  var CHARS_PER_TOKEN = 4;
571
307
  function estimateTokens(text) {
572
308
  return Math.ceil(text.length / CHARS_PER_TOKEN);
@@ -740,6 +476,7 @@ function extractVerdict(text) {
740
476
  const review = (before + after).replace(/\n{3,}/g, "\n\n").trim();
741
477
  return { verdict: verdictStr, review };
742
478
  }
479
+ console.warn("No verdict found in review output, defaulting to COMMENT");
743
480
  return { verdict: "comment", review: text };
744
481
  }
745
482
  async function executeReview(req, deps, runTool = executeTool) {
@@ -898,1126 +635,627 @@ ${userMessage}`;
898
635
  }
899
636
  }
900
637
 
901
- // src/consumption.ts
902
- async function checkConsumptionLimits(_agentId, _limits) {
903
- return { allowed: true };
904
- }
905
- function createSessionTracker() {
906
- return { tokens: 0, reviews: 0 };
907
- }
908
- function recordSessionUsage(session, tokensUsed) {
909
- session.tokens += tokensUsed;
910
- session.reviews += 1;
911
- }
912
- function formatPostReviewStats(_tokensUsed, session, _limits, _dailyStats) {
913
- return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
914
- }
915
-
916
- // src/commands/agent.ts
917
- var CONNECTION_STABILITY_THRESHOLD_MS = 3e4;
918
- function formatTable(agents, trustLabels) {
919
- if (agents.length === 0) {
920
- console.log("No agents registered. Run `opencara agent create` to register one.");
921
- return;
922
- }
923
- const header = [
924
- "ID".padEnd(38),
925
- "Name".padEnd(20),
926
- "Model".padEnd(22),
927
- "Tool".padEnd(16),
928
- "Status".padEnd(10),
929
- "Trust"
930
- ].join("");
931
- console.log(header);
932
- for (const a of agents) {
933
- const trust = trustLabels?.get(a.id) ?? "--";
934
- const name = a.displayName ?? "--";
935
- console.log(
936
- [
937
- a.id.padEnd(38),
938
- name.padEnd(20),
939
- a.model.padEnd(22),
940
- a.tool.padEnd(16),
941
- a.status.padEnd(10),
942
- trust
943
- ].join("")
944
- );
638
+ // src/router.ts
639
+ import * as readline from "readline";
640
+ var END_OF_RESPONSE = "<<<OPENCARA_END_RESPONSE>>>";
641
+ var RouterRelay = class {
642
+ pending = null;
643
+ responseLines = [];
644
+ rl = null;
645
+ stdout;
646
+ stderr;
647
+ stdin;
648
+ stopped = false;
649
+ constructor(deps) {
650
+ this.stdin = deps?.stdin ?? process.stdin;
651
+ this.stdout = deps?.stdout ?? process.stdout;
652
+ this.stderr = deps?.stderr ?? process.stderr;
653
+ }
654
+ /** Start listening for stdin input */
655
+ start() {
656
+ this.stopped = false;
657
+ this.rl = readline.createInterface({
658
+ input: this.stdin,
659
+ terminal: false
660
+ });
661
+ this.rl.on("line", (line) => {
662
+ this.handleLine(line);
663
+ });
664
+ this.rl.on("close", () => {
665
+ if (this.stopped) return;
666
+ if (this.pending) {
667
+ const response = this.responseLines.join("\n").trim();
668
+ this.responseLines = [];
669
+ clearTimeout(this.pending.timer);
670
+ const task = this.pending;
671
+ this.pending = null;
672
+ if (response.length >= 100) {
673
+ console.warn("Router stdin closed \u2014 accepting partial response");
674
+ task.resolve(response);
675
+ } else {
676
+ task.reject(new Error("Router process died (stdin closed with insufficient response)"));
677
+ }
678
+ }
679
+ });
945
680
  }
946
- }
947
- function buildWsUrl(platformUrl, agentId, apiKey) {
948
- return platformUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://") + `/ws/agent/${agentId}?token=${encodeURIComponent(apiKey)}`;
949
- }
950
- var HEARTBEAT_TIMEOUT_MS = 9e4;
951
- var STABILITY_THRESHOLD_MIN_MS = 5e3;
952
- var STABILITY_THRESHOLD_MAX_MS = 3e5;
953
- var WS_PING_INTERVAL_MS = 2e4;
954
- function startAgent(agentId, platformUrl, apiKey, reviewDeps, consumptionDeps, options) {
955
- const verbose = options?.verbose ?? false;
956
- const stabilityThreshold = options?.stabilityThresholdMs ?? CONNECTION_STABILITY_THRESHOLD_MS;
957
- const repoConfig = options?.repoConfig;
958
- const displayName = options?.displayName;
959
- const prefix = options?.label ? `[${options.label}]` : "";
960
- const log = (...args) => console.log(...prefix ? [prefix, ...args] : args);
961
- const logError = (...args) => console.error(...prefix ? [prefix, ...args] : args);
962
- let attempt = 0;
963
- let intentionalClose = false;
964
- let heartbeatTimer = null;
965
- let wsPingTimer = null;
966
- let currentWs = null;
967
- let connectionOpenedAt = null;
968
- let stabilityTimer = null;
969
- function clearHeartbeatTimer() {
970
- if (heartbeatTimer) {
971
- clearTimeout(heartbeatTimer);
972
- heartbeatTimer = null;
681
+ /** Stop listening and clean up */
682
+ stop() {
683
+ this.stopped = true;
684
+ if (this.rl) {
685
+ this.rl.close();
686
+ this.rl = null;
973
687
  }
974
- }
975
- function clearStabilityTimer() {
976
- if (stabilityTimer) {
977
- clearTimeout(stabilityTimer);
978
- stabilityTimer = null;
688
+ if (this.pending) {
689
+ clearTimeout(this.pending.timer);
690
+ this.pending.reject(new Error("Router relay stopped"));
691
+ this.pending = null;
692
+ this.responseLines = [];
979
693
  }
980
694
  }
981
- function clearWsPingTimer() {
982
- if (wsPingTimer) {
983
- clearInterval(wsPingTimer);
984
- wsPingTimer = null;
695
+ /** Write the prompt as plain text to stdout */
696
+ writePrompt(prompt) {
697
+ try {
698
+ this.stdout.write(prompt + "\n");
699
+ } catch (err) {
700
+ throw new Error(`Failed to write to router: ${err.message}`);
985
701
  }
986
702
  }
987
- function shutdown() {
988
- intentionalClose = true;
989
- clearHeartbeatTimer();
990
- clearStabilityTimer();
991
- clearWsPingTimer();
992
- if (currentWs) currentWs.close();
993
- log("Disconnected.");
994
- process.exit(0);
703
+ /** Write a status message to stderr (doesn't interfere with prompt/response on stdout/stdin) */
704
+ writeStatus(message) {
705
+ this.stderr.write(message + "\n");
995
706
  }
996
- process.once("SIGINT", shutdown);
997
- process.once("SIGTERM", shutdown);
998
- function connect() {
999
- const url = buildWsUrl(platformUrl, agentId, apiKey);
1000
- const ws = new WebSocket(url);
1001
- currentWs = ws;
1002
- function resetHeartbeatTimer() {
1003
- clearHeartbeatTimer();
1004
- heartbeatTimer = setTimeout(() => {
1005
- log("No heartbeat received in 90s. Reconnecting...");
1006
- ws.terminate();
1007
- }, HEARTBEAT_TIMEOUT_MS);
1008
- }
1009
- ws.on("open", () => {
1010
- connectionOpenedAt = Date.now();
1011
- log("Connected to platform.");
1012
- resetHeartbeatTimer();
1013
- clearWsPingTimer();
1014
- wsPingTimer = setInterval(() => {
1015
- try {
1016
- if (ws.readyState === WebSocket.OPEN) {
1017
- ws.ping();
1018
- }
1019
- } catch {
1020
- }
1021
- }, WS_PING_INTERVAL_MS);
1022
- if (verbose) {
1023
- log(`[verbose] Connection opened at ${new Date(connectionOpenedAt).toISOString()}`);
707
+ /** Build the full prompt for a review request */
708
+ buildReviewPrompt(req) {
709
+ const systemPrompt = buildSystemPrompt(
710
+ req.owner,
711
+ req.repo,
712
+ req.reviewMode
713
+ );
714
+ const userMessage = buildUserMessage(req.prompt, req.diffContent);
715
+ return `${systemPrompt}
716
+
717
+ ${userMessage}`;
718
+ }
719
+ /** Build the full prompt for a summary request */
720
+ buildSummaryPrompt(req) {
721
+ const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
722
+ const userMessage = buildSummaryUserMessage(req.prompt, req.reviews, req.diffContent);
723
+ return `${systemPrompt}
724
+
725
+ ${userMessage}`;
726
+ }
727
+ /**
728
+ * Send a prompt to the external agent via stdout (plain text)
729
+ * and wait for the response via stdin (plain text, terminated by END_OF_RESPONSE or EOF).
730
+ */
731
+ sendPrompt(_type, _taskId, prompt, timeoutSec) {
732
+ return new Promise((resolve2, reject) => {
733
+ if (this.pending) {
734
+ reject(new Error("Another prompt is already pending"));
735
+ return;
1024
736
  }
1025
- clearStabilityTimer();
1026
- stabilityTimer = setTimeout(() => {
1027
- if (verbose) {
1028
- log(
1029
- `[verbose] Connection stable for ${stabilityThreshold / 1e3}s \u2014 resetting reconnect counter`
1030
- );
1031
- }
1032
- attempt = 0;
1033
- }, stabilityThreshold);
1034
- });
1035
- ws.on("message", (data) => {
1036
- let msg;
737
+ const timeoutMs = timeoutSec * 1e3;
738
+ this.responseLines = [];
739
+ const timer = setTimeout(() => {
740
+ this.pending = null;
741
+ this.responseLines = [];
742
+ reject(new RouterTimeoutError(`Response timeout (${timeoutSec}s)`));
743
+ }, timeoutMs);
744
+ this.pending = { resolve: resolve2, reject, timer };
1037
745
  try {
1038
- msg = JSON.parse(data.toString());
1039
- } catch {
1040
- return;
746
+ this.writePrompt(prompt);
747
+ } catch (err) {
748
+ clearTimeout(timer);
749
+ this.pending = null;
750
+ this.responseLines = [];
751
+ reject(err);
1041
752
  }
1042
- handleMessage(
1043
- ws,
1044
- msg,
1045
- resetHeartbeatTimer,
1046
- reviewDeps,
1047
- consumptionDeps,
1048
- verbose,
1049
- repoConfig,
1050
- displayName,
1051
- prefix
1052
- );
1053
753
  });
1054
- ws.on("close", (code, reason) => {
1055
- if (intentionalClose) return;
1056
- if (ws !== currentWs) return;
1057
- clearHeartbeatTimer();
1058
- clearStabilityTimer();
1059
- clearWsPingTimer();
1060
- if (connectionOpenedAt) {
1061
- const lifetimeMs = Date.now() - connectionOpenedAt;
1062
- const lifetimeSec = (lifetimeMs / 1e3).toFixed(1);
1063
- log(
1064
- `Disconnected (code=${code}, reason=${reason.toString()}). Connection was alive for ${lifetimeSec}s.`
1065
- );
1066
- } else {
1067
- log(`Disconnected (code=${code}, reason=${reason.toString()}).`);
1068
- }
1069
- if (code === 4002) {
1070
- log("Connection replaced by server \u2014 not reconnecting.");
754
+ }
755
+ /** Parse a review response: extract verdict and review text */
756
+ parseReviewResponse(response) {
757
+ return extractVerdict(response);
758
+ }
759
+ /** Get whether a task is pending (for testing) */
760
+ get pendingCount() {
761
+ return this.pending ? 1 : 0;
762
+ }
763
+ /** Handle a single line from stdin */
764
+ handleLine(line) {
765
+ if (!this.pending) return;
766
+ if (line.trim() === END_OF_RESPONSE) {
767
+ const response = this.responseLines.join("\n").trim();
768
+ this.responseLines = [];
769
+ clearTimeout(this.pending.timer);
770
+ const task = this.pending;
771
+ this.pending = null;
772
+ if (!response || response.length < 10) {
773
+ task.reject(new Error("Router returned empty or trivially short response"));
1071
774
  return;
1072
775
  }
1073
- connectionOpenedAt = null;
1074
- reconnect();
1075
- });
1076
- ws.on("pong", () => {
1077
- if (verbose) {
1078
- log(`[verbose] WS pong received at ${(/* @__PURE__ */ new Date()).toISOString()}`);
1079
- }
1080
- });
1081
- ws.on("error", (err) => {
1082
- logError(`WebSocket error: ${err.message}`);
1083
- });
776
+ task.resolve(response);
777
+ return;
778
+ }
779
+ this.responseLines.push(line);
1084
780
  }
1085
- async function reconnect() {
1086
- const delay = calculateDelay(attempt, DEFAULT_RECONNECT_OPTIONS);
1087
- const delaySec = (delay / 1e3).toFixed(1);
1088
- attempt++;
1089
- log(`Reconnecting in ${delaySec}s... (attempt ${attempt})`);
1090
- await sleep(delay);
1091
- connect();
781
+ };
782
+ var RouterTimeoutError = class extends Error {
783
+ constructor(message) {
784
+ super(message);
785
+ this.name = "RouterTimeoutError";
1092
786
  }
1093
- connect();
787
+ };
788
+
789
+ // src/consumption.ts
790
+ function createSessionTracker() {
791
+ return { tokens: 0, reviews: 0 };
1094
792
  }
1095
- function trySend(ws, data) {
1096
- try {
1097
- ws.send(JSON.stringify(data));
1098
- } catch {
1099
- console.error("Failed to send message \u2014 WebSocket may be closed");
1100
- }
793
+ function recordSessionUsage(session, tokensUsed) {
794
+ session.tokens += tokensUsed;
795
+ session.reviews += 1;
1101
796
  }
1102
- async function logPostReviewStats(type, verdict, tokensUsed, tokensEstimated, consumptionDeps, logPrefix) {
1103
- const pfx = logPrefix ? `${logPrefix} ` : "";
1104
- const estimateTag = tokensEstimated ? " ~" : " ";
1105
- if (!consumptionDeps) {
1106
- if (verdict) {
1107
- console.log(
1108
- `${pfx}${type} complete: ${verdict} (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
1109
- );
1110
- } else {
1111
- console.log(
1112
- `${pfx}${type} complete (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ", estimated" : ""})`
1113
- );
1114
- }
1115
- return;
1116
- }
1117
- recordSessionUsage(consumptionDeps.session, tokensUsed);
1118
- if (verdict) {
1119
- console.log(
1120
- `${pfx}${type} complete: ${verdict} (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
1121
- );
1122
- } else {
1123
- console.log(
1124
- `${pfx}${type} complete (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ", estimated" : ""})`
1125
- );
1126
- }
1127
- console.log(
1128
- `${pfx}${formatPostReviewStats(tokensUsed, consumptionDeps.session, consumptionDeps.limits)}`
797
+ function formatPostReviewStats(session) {
798
+ return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
799
+ }
800
+
801
+ // src/commands/agent.ts
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}`);
812
+ }
813
+ return response.text();
814
+ },
815
+ { maxAttempts: 2 },
816
+ signal
1129
817
  );
1130
818
  }
1131
- function handleMessage(ws, msg, resetHeartbeat, reviewDeps, consumptionDeps, verbose, repoConfig, displayName, logPrefix) {
1132
- const pfx = logPrefix ? `${logPrefix} ` : "";
1133
- switch (msg.type) {
1134
- case "connected":
1135
- console.log(`${pfx}Authenticated. Protocol v${msg.version ?? "unknown"}`);
1136
- trySend(ws, {
1137
- type: "agent_preferences",
1138
- id: crypto2.randomUUID(),
1139
- timestamp: Date.now(),
1140
- ...displayName ? { displayName } : {},
1141
- repoConfig: repoConfig ?? { mode: "all" }
1142
- });
1143
- break;
1144
- case "heartbeat_ping":
1145
- ws.send(JSON.stringify({ type: "heartbeat_pong", timestamp: Date.now() }));
1146
- if (verbose) {
1147
- console.log(
1148
- `${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
1149
842
  );
1150
843
  }
1151
- if (resetHeartbeat) resetHeartbeat();
1152
- break;
1153
- case "review_request": {
1154
- const request = msg;
1155
- console.log(
1156
- `${pfx}Review request: task ${request.taskId} for ${request.project.owner}/${request.project.repo}#${request.pr.number}`
1157
- );
1158
- if (!reviewDeps) {
1159
- ws.send(
1160
- JSON.stringify({
1161
- type: "review_rejected",
1162
- id: crypto2.randomUUID(),
1163
- timestamp: Date.now(),
1164
- taskId: request.taskId,
1165
- reason: "Review execution not configured"
1166
- })
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}]`
1167
851
  );
1168
- break;
1169
- }
1170
- void (async () => {
1171
- if (consumptionDeps) {
1172
- const limitResult = await checkConsumptionLimits(
1173
- consumptionDeps.agentId,
1174
- consumptionDeps.limits
1175
- );
1176
- if (!limitResult.allowed) {
1177
- trySend(ws, {
1178
- type: "review_rejected",
1179
- id: crypto2.randomUUID(),
1180
- timestamp: Date.now(),
1181
- taskId: request.taskId,
1182
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1183
- });
1184
- console.log(`${pfx}Review rejected: ${limitResult.reason}`);
1185
- return;
1186
- }
852
+ if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
853
+ console.error("Authentication failed repeatedly. Exiting.");
854
+ break;
1187
855
  }
1188
- try {
1189
- const result = await executeReview(
1190
- {
1191
- taskId: request.taskId,
1192
- diffContent: request.diffContent,
1193
- prompt: request.project.prompt,
1194
- owner: request.project.owner,
1195
- repo: request.project.repo,
1196
- prNumber: request.pr.number,
1197
- timeout: request.timeout,
1198
- reviewMode: request.reviewMode ?? "full"
1199
- },
1200
- reviewDeps
1201
- );
1202
- trySend(ws, {
1203
- type: "review_complete",
1204
- id: crypto2.randomUUID(),
1205
- timestamp: Date.now(),
1206
- taskId: request.taskId,
1207
- review: result.review,
1208
- verdict: result.verdict,
1209
- tokensUsed: result.tokensUsed
1210
- });
1211
- await logPostReviewStats(
1212
- "Review",
1213
- result.verdict,
1214
- result.tokensUsed,
1215
- result.tokensEstimated,
1216
- consumptionDeps,
1217
- logPrefix
1218
- );
1219
- } catch (err) {
1220
- if (err instanceof DiffTooLargeError) {
1221
- trySend(ws, {
1222
- type: "review_rejected",
1223
- id: crypto2.randomUUID(),
1224
- timestamp: Date.now(),
1225
- taskId: request.taskId,
1226
- reason: err.message
1227
- });
1228
- } else {
1229
- trySend(ws, {
1230
- type: "review_error",
1231
- id: crypto2.randomUUID(),
1232
- timestamp: Date.now(),
1233
- taskId: request.taskId,
1234
- error: err instanceof Error ? err.message : "Unknown error"
1235
- });
1236
- }
1237
- console.error(`${pfx}Review failed:`, err);
1238
- }
1239
- })();
1240
- break;
1241
- }
1242
- case "summary_request": {
1243
- const summaryRequest = msg;
1244
- console.log(
1245
- `${pfx}Summary request: task ${summaryRequest.taskId} for ${summaryRequest.project.owner}/${summaryRequest.project.repo}#${summaryRequest.pr.number} (${summaryRequest.reviews.length} reviews)`
1246
- );
1247
- if (!reviewDeps) {
1248
- trySend(ws, {
1249
- type: "review_rejected",
1250
- id: crypto2.randomUUID(),
1251
- timestamp: Date.now(),
1252
- taskId: summaryRequest.taskId,
1253
- reason: "Review tool not configured"
1254
- });
1255
- break;
856
+ } else {
857
+ consecutiveAuthErrors = 0;
858
+ consecutiveErrors++;
859
+ console.error(`Poll error: ${err.message}`);
1256
860
  }
1257
- void (async () => {
1258
- if (consumptionDeps) {
1259
- const limitResult = await checkConsumptionLimits(
1260
- consumptionDeps.agentId,
1261
- consumptionDeps.limits
1262
- );
1263
- if (!limitResult.allowed) {
1264
- trySend(ws, {
1265
- type: "review_rejected",
1266
- id: crypto2.randomUUID(),
1267
- timestamp: Date.now(),
1268
- taskId: summaryRequest.taskId,
1269
- reason: limitResult.reason ?? "consumption_limit_exceeded"
1270
- });
1271
- console.log(`${pfx}Summary rejected: ${limitResult.reason}`);
1272
- return;
1273
- }
1274
- }
1275
- try {
1276
- const result = await executeSummary(
1277
- {
1278
- taskId: summaryRequest.taskId,
1279
- reviews: summaryRequest.reviews,
1280
- prompt: summaryRequest.project.prompt,
1281
- owner: summaryRequest.project.owner,
1282
- repo: summaryRequest.project.repo,
1283
- prNumber: summaryRequest.pr.number,
1284
- timeout: summaryRequest.timeout,
1285
- diffContent: summaryRequest.diffContent ?? ""
1286
- },
1287
- reviewDeps
1288
- );
1289
- trySend(ws, {
1290
- type: "summary_complete",
1291
- id: crypto2.randomUUID(),
1292
- timestamp: Date.now(),
1293
- taskId: summaryRequest.taskId,
1294
- summary: result.summary,
1295
- tokensUsed: result.tokensUsed
1296
- });
1297
- await logPostReviewStats(
1298
- "Summary",
1299
- void 0,
1300
- result.tokensUsed,
1301
- result.tokensEstimated,
1302
- consumptionDeps,
1303
- 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`
1304
870
  );
1305
- } catch (err) {
1306
- if (err instanceof InputTooLargeError) {
1307
- trySend(ws, {
1308
- type: "review_rejected",
1309
- id: crypto2.randomUUID(),
1310
- timestamp: Date.now(),
1311
- taskId: summaryRequest.taskId,
1312
- reason: err.message
1313
- });
1314
- } else {
1315
- trySend(ws, {
1316
- type: "review_error",
1317
- id: crypto2.randomUUID(),
1318
- timestamp: Date.now(),
1319
- taskId: summaryRequest.taskId,
1320
- error: err instanceof Error ? err.message : "Summary failed"
1321
- });
1322
- }
1323
- console.error(`${pfx}Summary failed:`, err);
871
+ await sleep2(extraDelay, signal);
1324
872
  }
1325
- })();
1326
- break;
1327
- }
1328
- case "error":
1329
- console.error(`${pfx}Platform error: ${msg.code ?? "unknown"}`);
1330
- if (msg.code === "auth_revoked") process.exit(1);
1331
- break;
1332
- default:
1333
- break;
1334
- }
1335
- }
1336
- async function syncAgentToServer(client, serverAgents, localAgent) {
1337
- const existing = serverAgents.find(
1338
- (a) => a.model === localAgent.model && a.tool === localAgent.tool
1339
- );
1340
- if (existing) {
1341
- return { agentId: existing.id, created: false };
1342
- }
1343
- const body = { model: localAgent.model, tool: localAgent.tool };
1344
- if (localAgent.name) {
1345
- body.displayName = localAgent.name;
1346
- }
1347
- if (localAgent.repos) {
1348
- body.repoConfig = localAgent.repos;
1349
- }
1350
- const created = await client.post("/api/agents", body);
1351
- return { agentId: created.id, created: true };
1352
- }
1353
- function resolveLocalAgentCommand(localAgent, globalAgentCommand) {
1354
- const effectiveCommand = localAgent.command ?? globalAgentCommand;
1355
- return resolveCommandTemplate(effectiveCommand);
1356
- }
1357
- var agentCommand = new Command2("agent").description("Manage review agents");
1358
- 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) => {
1359
- const config = loadConfig();
1360
- requireApiKey(config);
1361
- let model;
1362
- let tool;
1363
- let command = opts.command;
1364
- if (opts.model && opts.tool) {
1365
- model = opts.model;
1366
- tool = opts.tool;
1367
- } else if (opts.model || opts.tool) {
1368
- console.error("Both --model and --tool are required in non-interactive mode.");
1369
- process.exit(1);
1370
- } else {
1371
- const client = new ApiClient(config.platformUrl, config.apiKey);
1372
- let registry;
1373
- try {
1374
- registry = await client.get("/api/registry");
1375
- } catch {
1376
- console.warn("Could not fetch registry from server. Using built-in defaults.");
1377
- registry = DEFAULT_REGISTRY;
1378
- }
1379
- const { search, input } = await import("@inquirer/prompts");
1380
- const searchTheme = {
1381
- style: {
1382
- keysHelpTip: (keys) => keys.map(([key, action]) => `${key} ${action}`).join(", ") + ", ^C exit"
1383
- }
1384
- };
1385
- const existingAgents = config.agents ?? [];
1386
- const toolChoices = registry.tools.map((t) => ({
1387
- name: t.displayName,
1388
- value: t.name
1389
- }));
1390
- try {
1391
- while (true) {
1392
- tool = await search({
1393
- message: "Select a tool:",
1394
- theme: searchTheme,
1395
- source: (term) => {
1396
- const q = (term ?? "").toLowerCase();
1397
- return toolChoices.filter(
1398
- (c) => c.name.toLowerCase().includes(q) || c.value.toLowerCase().includes(q)
1399
- );
1400
- }
1401
- });
1402
- const compatible = registry.models.filter((m) => m.tools.includes(tool));
1403
- const incompatible = registry.models.filter((m) => !m.tools.includes(tool));
1404
- const modelChoices = [
1405
- ...compatible.map((m) => ({
1406
- name: m.displayName,
1407
- value: m.name
1408
- })),
1409
- ...incompatible.map((m) => ({
1410
- name: `\x1B[38;5;249m${m.displayName}\x1B[0m`,
1411
- value: m.name
1412
- }))
1413
- ];
1414
- model = await search({
1415
- message: "Select a model:",
1416
- theme: searchTheme,
1417
- source: (term) => {
1418
- const q = (term ?? "").toLowerCase();
1419
- return modelChoices.filter(
1420
- (c) => c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)
1421
- );
1422
- }
1423
- });
1424
- const isDup = existingAgents.some((a) => a.model === model && a.tool === tool);
1425
- if (isDup) {
1426
- console.warn(`"${model}" / "${tool}" already exists in config. Choose again.`);
1427
- continue;
1428
- }
1429
- const modelEntry = registry.models.find((m) => m.name === model);
1430
- if (modelEntry && !modelEntry.tools.includes(tool)) {
1431
- console.warn(`Warning: "${model}" is not listed as compatible with "${tool}".`);
1432
- }
1433
- break;
1434
- }
1435
- const toolEntry = registry.tools.find((t) => t.name === tool);
1436
- const defaultCommand = toolEntry ? toolEntry.commandTemplate.replaceAll("${MODEL}", model) : `${tool} --model ${model} -p \${PROMPT}`;
1437
- command = await input({
1438
- message: "Command:",
1439
- default: defaultCommand,
1440
- prefill: "editable"
1441
- });
1442
- } catch (err) {
1443
- if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
1444
- console.log("Cancelled.");
1445
- return;
1446
873
  }
1447
- throw err;
1448
874
  }
875
+ await sleep2(pollIntervalMs, signal);
1449
876
  }
1450
- if (!command) {
1451
- const toolEntry = DEFAULT_REGISTRY.tools.find((t) => t.name === tool);
1452
- if (toolEntry) {
1453
- command = toolEntry.commandTemplate.replaceAll("${MODEL}", model);
1454
- } else {
1455
- console.error(`No command template for tool "${tool}". Use --command to specify one.`);
1456
- process.exit(1);
1457
- }
1458
- }
1459
- if (validateCommandBinary(command)) {
1460
- console.log(`Verifying... binary found.`);
1461
- } else {
1462
- console.warn(
1463
- `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
1464
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;
1465
898
  }
1466
- const newAgent = { model, tool, command };
1467
- if (config.agents === null) {
1468
- config.agents = [];
1469
- }
1470
- const isDuplicate = config.agents.some((a) => a.model === model && a.tool === tool);
1471
- if (isDuplicate) {
1472
- console.error(`Agent with model "${model}" and tool "${tool}" already exists in config.`);
1473
- process.exit(1);
899
+ if (!claimResponse.claimed) {
900
+ console.log(` Claim rejected: ${claimResponse.reason}`);
901
+ return;
1474
902
  }
1475
- config.agents.push(newAgent);
1476
- saveConfig(config);
1477
- console.log("Agent added to config:");
1478
- console.log(` Model: ${model}`);
1479
- console.log(` Tool: ${tool}`);
1480
- console.log(` Command: ${command}`);
1481
- });
1482
- agentCommand.command("init").description("Import server-side agents into local config").action(async () => {
1483
- const config = loadConfig();
1484
- const apiKey = requireApiKey(config);
1485
- const client = new ApiClient(config.platformUrl, apiKey);
1486
- let res;
903
+ console.log(` Claimed as ${role}`);
904
+ let diffContent;
1487
905
  try {
1488
- res = await client.get("/api/agents");
906
+ diffContent = await fetchDiff(diff_url, signal);
907
+ console.log(` Diff fetched (${Math.round(diffContent.length / 1024)}KB)`);
1489
908
  } catch (err) {
1490
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1491
- process.exit(1);
1492
- }
1493
- if (res.agents.length === 0) {
1494
- 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}`);
1495
911
  return;
1496
912
  }
1497
- let registry;
1498
913
  try {
1499
- registry = await client.get("/api/registry");
1500
- } catch {
1501
- registry = DEFAULT_REGISTRY;
1502
- }
1503
- const toolCommands = new Map(registry.tools.map((t) => [t.name, t.commandTemplate]));
1504
- const existing = config.agents ?? [];
1505
- let imported = 0;
1506
- for (const agent of res.agents) {
1507
- const isDuplicate = existing.some((e) => e.model === agent.model && e.tool === agent.tool);
1508
- if (isDuplicate) continue;
1509
- let command = toolCommands.get(agent.tool);
1510
- if (command) {
1511
- 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
+ );
1512
931
  } else {
1513
- console.warn(
1514
- `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
1515
946
  );
1516
947
  }
1517
- existing.push({ model: agent.model, tool: agent.tool, command });
1518
- imported++;
1519
- }
1520
- config.agents = existing;
1521
- saveConfig(config);
1522
- console.log(`Imported ${imported} agent(s) to local config.`);
1523
- if (imported > 0) {
1524
- console.log("Edit ~/.opencara/config.yml to adjust commands for your system.");
1525
- }
1526
- });
1527
- agentCommand.command("list").description("List registered agents").action(async () => {
1528
- const config = loadConfig();
1529
- const apiKey = requireApiKey(config);
1530
- const client = new ApiClient(config.platformUrl, apiKey);
1531
- let res;
1532
- try {
1533
- res = await client.get("/api/agents");
1534
948
  } catch (err) {
1535
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1536
- process.exit(1);
1537
- }
1538
- const trustLabels = /* @__PURE__ */ new Map();
1539
- for (const agent of res.agents) {
1540
- try {
1541
- const stats = await client.get(`/api/stats/${agent.id}`);
1542
- trustLabels.set(agent.id, stats.agent.trustTier.label);
1543
- } 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);
1544
955
  }
1545
956
  }
1546
- formatTable(res.agents, trustLabels);
1547
- });
1548
- async function resolveAnonymousAgent(config, model, tool) {
1549
- const existing = config.anonymousAgents.find((a) => a.model === model && a.tool === tool);
1550
- if (existing) {
1551
- console.log(`Reusing stored anonymous agent ${existing.agentId} (${model} / ${tool})`);
1552
- const command2 = resolveCommandTemplate(
1553
- 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)`
1554
967
  );
1555
- return { entry: existing, command: command2 };
1556
968
  }
1557
- console.log("Registering anonymous agent...");
1558
- const client = new ApiClient(config.platformUrl);
1559
- const body = { model, tool };
1560
- const res = await client.post("/api/agents/anonymous", body);
1561
- const entry = {
1562
- agentId: res.agentId,
1563
- apiKey: res.apiKey,
1564
- model,
1565
- tool
1566
- };
1567
- config.anonymousAgents.push(entry);
1568
- saveConfig(config);
1569
- console.log(`Agent registered: ${res.agentId} (${model} / ${tool})`);
1570
- console.log("Credentials saved to ~/.opencara/config.yml");
1571
- const command = resolveCommandTemplate(
1572
- DEFAULT_REGISTRY.tools.find((t) => t.name === tool)?.commandTemplate.replaceAll("${MODEL}", model) ?? null
1573
- );
1574
- return { entry, command };
1575
969
  }
1576
- 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(
1577
- "--stability-threshold <ms>",
1578
- `Connection stability threshold in ms (${STABILITY_THRESHOLD_MIN_MS}\u2013${STABILITY_THRESHOLD_MAX_MS}, default: ${CONNECTION_STABILITY_THRESHOLD_MS})`
1579
- ).action(
1580
- async (agentIdOrModel, opts) => {
1581
- let stabilityThresholdMs;
1582
- if (opts.stabilityThreshold !== void 0) {
1583
- const val = Number(opts.stabilityThreshold);
1584
- if (!Number.isInteger(val) || val < STABILITY_THRESHOLD_MIN_MS || val > STABILITY_THRESHOLD_MAX_MS) {
1585
- console.error(
1586
- `Invalid --stability-threshold: must be an integer between ${STABILITY_THRESHOLD_MIN_MS} and ${STABILITY_THRESHOLD_MAX_MS}`
1587
- );
1588
- process.exit(1);
1589
- }
1590
- stabilityThresholdMs = val;
1591
- }
1592
- const config = loadConfig();
1593
- if (opts.anonymous) {
1594
- if (!opts.model || !opts.tool) {
1595
- console.error("Both --model and --tool are required with --anonymous.");
1596
- process.exit(1);
1597
- }
1598
- let resolved;
1599
- try {
1600
- resolved = await resolveAnonymousAgent(config, opts.model, opts.tool);
1601
- } catch (err) {
1602
- console.error(
1603
- "Failed to register anonymous agent:",
1604
- err instanceof Error ? err.message : err
1605
- );
1606
- process.exit(1);
1607
- }
1608
- const { entry, command } = resolved;
1609
- let reviewDeps2;
1610
- if (validateCommandBinary(command)) {
1611
- reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
1612
- } else {
1613
- console.warn(
1614
- `Warning: binary "${command.split(" ")[0]}" not found. Reviews will be rejected.`
1615
- );
1616
- }
1617
- const consumptionDeps2 = {
1618
- agentId: entry.agentId,
1619
- limits: config.limits,
1620
- session: createSessionTracker()
1621
- };
1622
- console.log(`Starting anonymous agent ${entry.agentId}...`);
1623
- startAgent(entry.agentId, config.platformUrl, entry.apiKey, reviewDeps2, consumptionDeps2, {
1624
- verbose: opts.verbose,
1625
- stabilityThresholdMs,
1626
- displayName: entry.name,
1627
- repoConfig: entry.repoConfig
1628
- });
1629
- return;
1630
- }
1631
- if (config.agents !== null) {
1632
- const validAgents = [];
1633
- for (const local of config.agents) {
1634
- let cmd;
1635
- try {
1636
- cmd = resolveLocalAgentCommand(local, config.agentCommand);
1637
- } catch (err) {
1638
- console.warn(
1639
- `Skipping ${local.model}/${local.tool}: ${err instanceof Error ? err.message : "no command template available"}`
1640
- );
1641
- continue;
1642
- }
1643
- if (!validateCommandBinary(cmd)) {
1644
- console.warn(
1645
- `Skipping ${local.model}/${local.tool}: binary "${cmd.split(" ")[0]}" not found`
1646
- );
1647
- continue;
1648
- }
1649
- validAgents.push({ local, command: cmd });
1650
- }
1651
- if (validAgents.length === 0 && config.anonymousAgents.length === 0) {
1652
- console.error("No valid agents in config. Check that tool binaries are installed.");
1653
- process.exit(1);
1654
- }
1655
- let agentsToStart;
1656
- const anonAgentsToStart = [];
1657
- if (opts.all) {
1658
- agentsToStart = validAgents;
1659
- anonAgentsToStart.push(...config.anonymousAgents);
1660
- } else if (agentIdOrModel) {
1661
- const match = validAgents.find((a) => a.local.model === agentIdOrModel);
1662
- if (!match) {
1663
- console.error(`No agent with model "${agentIdOrModel}" found in local config.`);
1664
- console.error("Available agents:");
1665
- for (const a of validAgents) {
1666
- console.error(` ${a.local.model} (${a.local.tool})`);
1667
- }
1668
- process.exit(1);
1669
- }
1670
- agentsToStart = [match];
1671
- } else if (validAgents.length === 1) {
1672
- agentsToStart = [validAgents[0]];
1673
- console.log(`Using agent ${validAgents[0].local.model} (${validAgents[0].local.tool})`);
1674
- } else if (validAgents.length === 0) {
1675
- console.error("No valid authenticated agents in config. Use --anonymous or --all.");
1676
- process.exit(1);
1677
- } else {
1678
- console.error("Multiple agents in config. Specify a model name or use --all:");
1679
- for (const a of validAgents) {
1680
- console.error(` ${a.local.model} (${a.local.tool})`);
1681
- }
1682
- process.exit(1);
1683
- }
1684
- const totalAgents = agentsToStart.length + anonAgentsToStart.length;
1685
- if (totalAgents > 1) {
1686
- process.setMaxListeners(process.getMaxListeners() + totalAgents * 2);
1687
- }
1688
- let startedCount = 0;
1689
- let apiKey2;
1690
- let client2;
1691
- let serverAgents;
1692
- if (agentsToStart.length > 0) {
1693
- apiKey2 = requireApiKey(config);
1694
- client2 = new ApiClient(config.platformUrl, apiKey2);
1695
- try {
1696
- const res = await client2.get("/api/agents");
1697
- serverAgents = res.agents;
1698
- } catch (err) {
1699
- console.error("Failed to fetch agents:", err instanceof Error ? err.message : err);
1700
- process.exit(1);
1701
- }
1702
- }
1703
- for (const selected of agentsToStart) {
1704
- let agentId2;
1705
- try {
1706
- const sync = await syncAgentToServer(client2, serverAgents, selected.local);
1707
- agentId2 = sync.agentId;
1708
- if (sync.created) {
1709
- console.log(`Registered new agent ${agentId2} on platform`);
1710
- serverAgents.push({
1711
- id: agentId2,
1712
- model: selected.local.model,
1713
- tool: selected.local.tool,
1714
- isAnonymous: false,
1715
- status: "offline",
1716
- repoConfig: null,
1717
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
1718
- });
1719
- }
1720
- } catch (err) {
1721
- console.error(
1722
- `Failed to sync agent ${selected.local.model} to server:`,
1723
- err instanceof Error ? err.message : err
1724
- );
1725
- continue;
1726
- }
1727
- const reviewDeps2 = {
1728
- commandTemplate: selected.command,
1729
- maxDiffSizeKb: config.maxDiffSizeKb
1730
- };
1731
- const consumptionDeps2 = {
1732
- agentId: agentId2,
1733
- limits: resolveAgentLimits(selected.local.limits, config.limits),
1734
- session: createSessionTracker()
1735
- };
1736
- const label = selected.local.name || selected.local.model || "unnamed";
1737
- console.log(`Starting agent ${label} (${agentId2})...`);
1738
- startAgent(agentId2, config.platformUrl, apiKey2, reviewDeps2, consumptionDeps2, {
1739
- verbose: opts.verbose,
1740
- stabilityThresholdMs,
1741
- repoConfig: selected.local.repos,
1742
- label
1743
- });
1744
- startedCount++;
1745
- }
1746
- for (const anon of anonAgentsToStart) {
1747
- let command;
1748
- try {
1749
- command = resolveCommandTemplate(
1750
- DEFAULT_REGISTRY.tools.find((t) => t.name === anon.tool)?.commandTemplate.replaceAll("${MODEL}", anon.model) ?? null
1751
- );
1752
- } catch {
1753
- console.warn(
1754
- `Skipping anonymous agent ${anon.agentId}: no command template for tool "${anon.tool}"`
1755
- );
1756
- continue;
1757
- }
1758
- let reviewDeps2;
1759
- if (validateCommandBinary(command)) {
1760
- reviewDeps2 = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
1761
- } else {
1762
- console.warn(
1763
- `Warning: binary "${command.split(" ")[0]}" not found for anonymous agent ${anon.agentId}. Reviews will be rejected.`
1764
- );
1765
- }
1766
- const consumptionDeps2 = {
1767
- agentId: anon.agentId,
1768
- limits: config.limits,
1769
- session: createSessionTracker()
1770
- };
1771
- const anonLabel = anon.name || anon.model || "anonymous";
1772
- console.log(`Starting anonymous agent ${anonLabel} (${anon.agentId})...`);
1773
- startAgent(anon.agentId, config.platformUrl, anon.apiKey, reviewDeps2, consumptionDeps2, {
1774
- verbose: opts.verbose,
1775
- stabilityThresholdMs,
1776
- displayName: anon.name,
1777
- repoConfig: anon.repoConfig,
1778
- label: anonLabel
1779
- });
1780
- startedCount++;
1781
- }
1782
- if (startedCount === 0) {
1783
- console.error("No agents could be started.");
1784
- process.exit(1);
1785
- }
1786
- return;
1787
- }
1788
- const apiKey = requireApiKey(config);
1789
- const client = new ApiClient(config.platformUrl, apiKey);
1790
- console.log(
1791
- "Hint: No agents in local config. Run `opencara agent init` to import, or `opencara agent create` to add agents."
1792
- );
1793
- let agentId = agentIdOrModel;
1794
- if (!agentId) {
1795
- let res;
1796
- try {
1797
- res = await client.get("/api/agents");
1798
- } catch (err) {
1799
- console.error("Failed to list agents:", err instanceof Error ? err.message : err);
1800
- process.exit(1);
1801
- }
1802
- if (res.agents.length === 0) {
1803
- console.error("No agents registered. Run `opencara agent create` first.");
1804
- process.exit(1);
1805
- }
1806
- if (res.agents.length === 1) {
1807
- agentId = res.agents[0].id;
1808
- console.log(`Using agent ${agentId}`);
1809
- } else {
1810
- console.error("Multiple agents found. Please specify an agent ID:");
1811
- for (const a of res.agents) {
1812
- console.error(` ${a.id} ${a.model} / ${a.tool}`);
1813
- }
1814
- process.exit(1);
1815
- }
1816
- }
1817
- let reviewDeps;
1818
- try {
1819
- const commandTemplate = resolveCommandTemplate(config.agentCommand);
1820
- reviewDeps = {
1821
- commandTemplate,
1822
- maxDiffSizeKb: config.maxDiffSizeKb
1823
- };
1824
- } catch (err) {
1825
- console.warn(
1826
- `Warning: ${err instanceof Error ? err.message : "Could not determine agent command."} Reviews will be rejected.`
1827
- );
1828
- }
1829
- const consumptionDeps = {
1830
- agentId,
1831
- limits: config.limits,
1832
- session: createSessionTracker()
1833
- };
1834
- console.log(`Starting agent ${agentId}...`);
1835
- startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
1836
- verbose: opts.verbose,
1837
- stabilityThresholdMs,
1838
- label: agentId
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
1839
974
  });
975
+ } catch (err) {
976
+ console.error(
977
+ ` Failed to report error for task ${taskId}: ${err.message} (logged locally)`
978
+ );
1840
979
  }
1841
- );
1842
-
1843
- // src/commands/stats.ts
1844
- import { Command as Command3 } from "commander";
1845
- function formatTrustTier(tier) {
1846
- const lines = [];
1847
- const pctPositive = Math.round(tier.positiveRate * 100);
1848
- lines.push(` Trust: ${tier.label} (${tier.reviewCount} reviews, ${pctPositive}% positive)`);
1849
- if (tier.nextTier) {
1850
- const pctProgress = Math.round(tier.progressToNext * 100);
1851
- const nextLabel = tier.nextTier.charAt(0).toUpperCase() + tier.nextTier.slice(1);
1852
- lines.push(` Progress to ${nextLabel}: ${pctProgress}%`);
1853
- }
1854
- return lines.join("\n");
1855
980
  }
1856
- function formatReviewQuality(stats) {
1857
- const lines = [];
1858
- lines.push(` Reviews: ${stats.totalReviews} completed, ${stats.totalSummaries} summaries`);
1859
- const totalRatings = stats.thumbsUp + stats.thumbsDown;
1860
- if (totalRatings > 0) {
1861
- const pctPositive = Math.round(stats.thumbsUp / totalRatings * 100);
1862
- 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);
1863
1003
  } else {
1864
- lines.push(` Quality: No ratings yet`);
1865
- }
1866
- 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));
1867
1035
  }
1868
- function formatRepoConfig(repoConfig) {
1869
- if (!repoConfig) return " Repos: all (default)";
1870
- switch (repoConfig.mode) {
1871
- case "all":
1872
- return " Repos: all";
1873
- case "own":
1874
- return " Repos: own repos only";
1875
- case "whitelist":
1876
- return ` Repos: whitelist (${repoConfig.list?.join(", ") ?? "none"})`;
1877
- case "blacklist":
1878
- return ` Repos: blacklist (${repoConfig.list?.join(", ") ?? "none"})`;
1879
- default:
1880
- 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
+ );
1881
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));
1882
1109
  }
1883
- function formatAgentStats(agent, agentStats) {
1884
- const lines = [];
1885
- lines.push(`Agent: ${agent.id} (${agent.model} / ${agent.tool})`);
1886
- lines.push(formatRepoConfig(agent.repoConfig));
1887
- if (agentStats) {
1888
- lines.push(formatTrustTier(agentStats.agent.trustTier));
1889
- lines.push(formatReviewQuality(agentStats.stats));
1890
- }
1891
- 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
+ });
1892
1126
  }
1893
- async function fetchAgentStats(client, agentId) {
1894
- try {
1895
- return await client.get(`/api/stats/${agentId}`);
1896
- } catch {
1897
- 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;
1898
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.");
1899
1153
  }
1900
- 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() {
1901
1155
  const config = loadConfig();
1902
- if (opts.agent) {
1903
- const anonEntry = findAnonymousAgent(config, opts.agent);
1904
- if (anonEntry) {
1905
- const anonClient = new ApiClient(config.platformUrl, anonEntry.apiKey);
1906
- const agent = {
1907
- id: anonEntry.agentId,
1908
- model: anonEntry.model,
1909
- tool: anonEntry.tool,
1910
- isAnonymous: true,
1911
- status: "offline",
1912
- repoConfig: anonEntry.repoConfig ?? null,
1913
- createdAt: ""
1914
- };
1915
- const agentStats = await fetchAgentStats(anonClient, anonEntry.agentId);
1916
- console.log(formatAgentStats(agent, agentStats));
1917
- return;
1918
- }
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;
1919
1164
  }
1920
- if (!config.apiKey && config.anonymousAgents.length > 0 && !opts.agent) {
1921
- const outputs2 = [];
1922
- for (const anon of config.anonymousAgents) {
1923
- const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
1924
- const agent = {
1925
- id: anon.agentId,
1926
- model: anon.model,
1927
- tool: anon.tool,
1928
- isAnonymous: true,
1929
- status: "offline",
1930
- repoConfig: anon.repoConfig ?? null,
1931
- createdAt: ""
1932
- };
1933
- try {
1934
- const agentStats = await fetchAgentStats(anonClient, anon.agentId);
1935
- outputs2.push(formatAgentStats(agent, agentStats));
1936
- } catch (err) {
1937
- outputs2.push(
1938
- `Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
1939
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
1940
- );
1941
- }
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
1942
1188
  }
1943
- console.log(outputs2.join("\n\n"));
1944
- 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;
1945
1207
  }
1946
- const apiKey = requireApiKey(config);
1947
- const client = new ApiClient(config.platformUrl, apiKey);
1948
- if (opts.agent) {
1949
- const agent = {
1950
- id: opts.agent,
1951
- model: "unknown",
1952
- tool: "unknown",
1953
- isAnonymous: false,
1954
- status: "offline",
1955
- repoConfig: null,
1956
- createdAt: ""
1957
- };
1958
- try {
1959
- const agentsRes2 = await client.get("/api/agents");
1960
- const found = agentsRes2.agents.find((a) => a.id === opts.agent);
1961
- if (found) {
1962
- agent.model = found.model;
1963
- agent.tool = found.tool;
1964
- }
1965
- } catch {
1966
- }
1967
- const agentStats = await fetchAgentStats(client, opts.agent);
1968
- 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);
1969
1213
  return;
1970
1214
  }
1971
- let agentsRes;
1972
- try {
1973
- agentsRes = await client.get("/api/agents");
1974
- } catch (err) {
1975
- 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]}`);
1976
1217
  process.exit(1);
1977
- }
1978
- if (agentsRes.agents.length === 0 && config.anonymousAgents.length === 0) {
1979
- console.log("No agents registered. Run `opencara agent create` to register one.");
1980
1218
  return;
1981
1219
  }
1982
- const outputs = [];
1983
- for (const agent of agentsRes.agents) {
1984
- try {
1985
- const agentStats = await fetchAgentStats(client, agent.id);
1986
- outputs.push(formatAgentStats(agent, agentStats));
1987
- } catch (err) {
1988
- outputs.push(
1989
- `Agent: ${agent.id} (${agent.model} / ${agent.tool})
1990
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
1991
- );
1992
- }
1993
- }
1994
- for (const anon of config.anonymousAgents) {
1995
- const anonClient = new ApiClient(config.platformUrl, anon.apiKey);
1996
- const agent = {
1997
- id: anon.agentId,
1998
- model: anon.model,
1999
- tool: anon.tool,
2000
- isAnonymous: true,
2001
- status: "offline",
2002
- repoConfig: anon.repoConfig ?? null,
2003
- createdAt: ""
2004
- };
2005
- try {
2006
- const agentStats = await fetchAgentStats(anonClient, anon.agentId);
2007
- outputs.push(formatAgentStats(agent, agentStats));
2008
- } catch (err) {
2009
- outputs.push(
2010
- `Agent: ${anon.agentId} (${anon.model} / ${anon.tool})
2011
- Error: ${err instanceof Error ? err.message : "Failed to fetch stats"}`
2012
- );
2013
- }
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();
2014
1252
  }
2015
- console.log(outputs.join("\n\n"));
2016
1253
  });
2017
1254
 
2018
1255
  // src/index.ts
2019
- var program = new Command4().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.8.0");
2020
- program.addCommand(loginCommand);
1256
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.10.0");
2021
1257
  program.addCommand(agentCommand);
2022
- program.addCommand(statsCommand);
1258
+ program.action(() => {
1259
+ startAgentRouter();
1260
+ });
2023
1261
  program.parse();