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