opencara 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.js +1938 -10
  2. package/package.json +4 -2
  3. package/dist/commands/agent.d.ts +0 -46
  4. package/dist/commands/agent.d.ts.map +0 -1
  5. package/dist/commands/agent.js +0 -924
  6. package/dist/commands/agent.js.map +0 -1
  7. package/dist/commands/login.d.ts +0 -5
  8. package/dist/commands/login.d.ts.map +0 -1
  9. package/dist/commands/login.js +0 -102
  10. package/dist/commands/login.js.map +0 -1
  11. package/dist/commands/stats.d.ts +0 -9
  12. package/dist/commands/stats.d.ts.map +0 -1
  13. package/dist/commands/stats.js +0 -187
  14. package/dist/commands/stats.js.map +0 -1
  15. package/dist/config.d.ts +0 -48
  16. package/dist/config.d.ts.map +0 -1
  17. package/dist/config.js +0 -227
  18. package/dist/config.js.map +0 -1
  19. package/dist/consumption.d.ts +0 -21
  20. package/dist/consumption.d.ts.map +0 -1
  21. package/dist/consumption.js +0 -18
  22. package/dist/consumption.js.map +0 -1
  23. package/dist/http.d.ts +0 -14
  24. package/dist/http.d.ts.map +0 -1
  25. package/dist/http.js +0 -59
  26. package/dist/http.js.map +0 -1
  27. package/dist/index.d.ts +0 -3
  28. package/dist/index.d.ts.map +0 -1
  29. package/dist/index.js.map +0 -1
  30. package/dist/reconnect.d.ts +0 -10
  31. package/dist/reconnect.d.ts.map +0 -1
  32. package/dist/reconnect.js +0 -17
  33. package/dist/reconnect.js.map +0 -1
  34. package/dist/review.d.ts +0 -34
  35. package/dist/review.d.ts.map +0 -1
  36. package/dist/review.js +0 -109
  37. package/dist/review.js.map +0 -1
  38. package/dist/summary.d.ts +0 -34
  39. package/dist/summary.d.ts.map +0 -1
  40. package/dist/summary.js +0 -90
  41. package/dist/summary.js.map +0 -1
  42. package/dist/tool-executor.d.ts +0 -50
  43. package/dist/tool-executor.d.ts.map +0 -1
  44. package/dist/tool-executor.js +0 -232
  45. package/dist/tool-executor.js.map +0 -1
@@ -1,924 +0,0 @@
1
- import { Command } from 'commander';
2
- import WebSocket from 'ws';
3
- import crypto from 'node:crypto';
4
- import { DEFAULT_REGISTRY, } from '@opencara/shared';
5
- import { loadConfig, saveConfig, requireApiKey, resolveAgentLimits, } from '../config.js';
6
- import { ApiClient } from '../http.js';
7
- import { calculateDelay, sleep, DEFAULT_RECONNECT_OPTIONS } from '../reconnect.js';
8
- import { executeReview, DiffTooLargeError } from '../review.js';
9
- import { executeSummary, InputTooLargeError } from '../summary.js';
10
- import { resolveCommandTemplate, validateCommandBinary } from '../tool-executor.js';
11
- import { checkConsumptionLimits, createSessionTracker, recordSessionUsage, formatPostReviewStats, } from '../consumption.js';
12
- /** Minimum time (ms) a connection must be alive before we reset the attempt counter */
13
- const CONNECTION_STABILITY_THRESHOLD_MS = 30_000;
14
- function formatTable(agents, trustLabels) {
15
- if (agents.length === 0) {
16
- console.log('No agents registered. Run `opencara agent create` to register one.');
17
- return;
18
- }
19
- const header = [
20
- 'ID'.padEnd(38),
21
- 'Model'.padEnd(22),
22
- 'Tool'.padEnd(16),
23
- 'Status'.padEnd(10),
24
- 'Trust',
25
- ].join('');
26
- console.log(header);
27
- for (const a of agents) {
28
- const trust = trustLabels?.get(a.id) ?? '--';
29
- console.log([a.id.padEnd(38), a.model.padEnd(22), a.tool.padEnd(16), a.status.padEnd(10), trust].join(''));
30
- }
31
- }
32
- function buildWsUrl(platformUrl, agentId, apiKey) {
33
- return (platformUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') +
34
- `/ws/agent/${agentId}?token=${encodeURIComponent(apiKey)}`);
35
- }
36
- export { buildWsUrl };
37
- const HEARTBEAT_TIMEOUT_MS = 90_000;
38
- export const STABILITY_THRESHOLD_MIN_MS = 5_000;
39
- export const STABILITY_THRESHOLD_MAX_MS = 300_000;
40
- /** Interval for sending RFC 6455 WebSocket ping frames to keep the proxy layer alive */
41
- const WS_PING_INTERVAL_MS = 20_000;
42
- export function startAgent(agentId, platformUrl, apiKey, reviewDeps, consumptionDeps, options) {
43
- const verbose = options?.verbose ?? false;
44
- const stabilityThreshold = options?.stabilityThresholdMs ?? CONNECTION_STABILITY_THRESHOLD_MS;
45
- const repoConfig = options?.repoConfig;
46
- let attempt = 0;
47
- let intentionalClose = false;
48
- let heartbeatTimer = null;
49
- let wsPingTimer = null;
50
- let currentWs = null;
51
- let connectionOpenedAt = null;
52
- let stabilityTimer = null;
53
- function clearHeartbeatTimer() {
54
- if (heartbeatTimer) {
55
- clearTimeout(heartbeatTimer);
56
- heartbeatTimer = null;
57
- }
58
- }
59
- function clearStabilityTimer() {
60
- if (stabilityTimer) {
61
- clearTimeout(stabilityTimer);
62
- stabilityTimer = null;
63
- }
64
- }
65
- function clearWsPingTimer() {
66
- if (wsPingTimer) {
67
- clearInterval(wsPingTimer);
68
- wsPingTimer = null;
69
- }
70
- }
71
- function shutdown() {
72
- intentionalClose = true;
73
- clearHeartbeatTimer();
74
- clearStabilityTimer();
75
- clearWsPingTimer();
76
- if (currentWs)
77
- currentWs.close();
78
- console.log('Disconnected.');
79
- process.exit(0);
80
- }
81
- process.once('SIGINT', shutdown);
82
- process.once('SIGTERM', shutdown);
83
- function connect() {
84
- const url = buildWsUrl(platformUrl, agentId, apiKey);
85
- const ws = new WebSocket(url);
86
- currentWs = ws;
87
- function resetHeartbeatTimer() {
88
- clearHeartbeatTimer();
89
- heartbeatTimer = setTimeout(() => {
90
- console.log('No heartbeat received in 90s. Reconnecting...');
91
- ws.terminate();
92
- }, HEARTBEAT_TIMEOUT_MS);
93
- }
94
- ws.on('open', () => {
95
- connectionOpenedAt = Date.now();
96
- console.log('Connected to platform.');
97
- resetHeartbeatTimer();
98
- // Send RFC 6455 WebSocket ping frames to keep the Cloudflare proxy layer alive
99
- clearWsPingTimer();
100
- wsPingTimer = setInterval(() => {
101
- try {
102
- if (ws.readyState === WebSocket.OPEN) {
103
- ws.ping();
104
- }
105
- }
106
- catch {
107
- // Swallow ping errors — socket may be closing
108
- }
109
- }, WS_PING_INTERVAL_MS);
110
- if (verbose) {
111
- console.log(`[verbose] Connection opened at ${new Date(connectionOpenedAt).toISOString()}`);
112
- }
113
- // Deferred attempt reset: only reset after connection is stable
114
- clearStabilityTimer();
115
- stabilityTimer = setTimeout(() => {
116
- if (verbose) {
117
- console.log(`[verbose] Connection stable for ${stabilityThreshold / 1000}s — resetting reconnect counter`);
118
- }
119
- attempt = 0;
120
- }, stabilityThreshold);
121
- });
122
- ws.on('message', (data) => {
123
- let msg;
124
- try {
125
- msg = JSON.parse(data.toString());
126
- }
127
- catch {
128
- return;
129
- }
130
- handleMessage(ws, msg, resetHeartbeatTimer, reviewDeps, consumptionDeps, verbose, repoConfig);
131
- });
132
- ws.on('close', (code, reason) => {
133
- if (intentionalClose)
134
- return;
135
- if (ws !== currentWs)
136
- return; // Stale WS — don't clear active timers
137
- clearHeartbeatTimer();
138
- clearStabilityTimer();
139
- clearWsPingTimer();
140
- // Log connection lifetime
141
- if (connectionOpenedAt) {
142
- const lifetimeMs = Date.now() - connectionOpenedAt;
143
- const lifetimeSec = (lifetimeMs / 1000).toFixed(1);
144
- console.log(`Disconnected (code=${code}, reason=${reason.toString()}). Connection was alive for ${lifetimeSec}s.`);
145
- }
146
- else {
147
- console.log(`Disconnected (code=${code}, reason=${reason.toString()}).`);
148
- }
149
- if (code === 4002) {
150
- console.log('Connection replaced by server — not reconnecting.');
151
- return;
152
- }
153
- connectionOpenedAt = null;
154
- reconnect();
155
- });
156
- ws.on('pong', () => {
157
- if (verbose) {
158
- console.log(`[verbose] WS pong received at ${new Date().toISOString()}`);
159
- }
160
- });
161
- ws.on('error', (err) => {
162
- console.error(`WebSocket error: ${err.message}`);
163
- });
164
- }
165
- async function reconnect() {
166
- const delay = calculateDelay(attempt, DEFAULT_RECONNECT_OPTIONS);
167
- const delaySec = (delay / 1000).toFixed(1);
168
- attempt++;
169
- console.log(`Reconnecting in ${delaySec}s... (attempt ${attempt})`);
170
- await sleep(delay);
171
- connect();
172
- }
173
- connect();
174
- }
175
- function trySend(ws, data) {
176
- try {
177
- ws.send(JSON.stringify(data));
178
- }
179
- catch {
180
- console.error('Failed to send message — WebSocket may be closed');
181
- }
182
- }
183
- async function logPostReviewStats(type, verdict, tokensUsed, tokensEstimated, consumptionDeps) {
184
- const estimateTag = tokensEstimated ? ' ~' : ' ';
185
- if (!consumptionDeps) {
186
- if (verdict) {
187
- console.log(`${type} complete: ${verdict} (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ', estimated' : ''})`);
188
- }
189
- else {
190
- console.log(`${type} complete (${estimateTag}${tokensUsed} tokens${tokensEstimated ? ', estimated' : ''})`);
191
- }
192
- return;
193
- }
194
- recordSessionUsage(consumptionDeps.session, tokensUsed);
195
- if (verdict) {
196
- console.log(`${type} complete: ${verdict} (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ', estimated' : ''})`);
197
- }
198
- else {
199
- console.log(`${type} complete (${estimateTag}${tokensUsed.toLocaleString()} tokens${tokensEstimated ? ', estimated' : ''})`);
200
- }
201
- console.log(formatPostReviewStats(tokensUsed, consumptionDeps.session, consumptionDeps.limits));
202
- }
203
- export function handleMessage(ws, msg, resetHeartbeat, reviewDeps, consumptionDeps, verbose, repoConfig) {
204
- switch (msg.type) {
205
- case 'connected':
206
- console.log(`Authenticated. Protocol v${msg.version ?? 'unknown'}`);
207
- // Send agent preferences (repo config) to the platform
208
- trySend(ws, {
209
- type: 'agent_preferences',
210
- id: crypto.randomUUID(),
211
- timestamp: Date.now(),
212
- repoConfig: repoConfig ?? { mode: 'all' },
213
- });
214
- break;
215
- case 'heartbeat_ping':
216
- ws.send(JSON.stringify({ type: 'heartbeat_pong', timestamp: Date.now() }));
217
- if (verbose) {
218
- console.log(`[verbose] Heartbeat ping received, pong sent at ${new Date().toISOString()}`);
219
- }
220
- if (resetHeartbeat)
221
- resetHeartbeat();
222
- break;
223
- case 'review_request': {
224
- const request = msg;
225
- console.log(`Review request: task ${request.taskId} for ${request.project.owner}/${request.project.repo}#${request.pr.number}`);
226
- if (!reviewDeps) {
227
- ws.send(JSON.stringify({
228
- type: 'review_rejected',
229
- id: crypto.randomUUID(),
230
- timestamp: Date.now(),
231
- taskId: request.taskId,
232
- reason: 'Review execution not configured',
233
- }));
234
- break;
235
- }
236
- void (async () => {
237
- // Check consumption limits before executing
238
- if (consumptionDeps) {
239
- const limitResult = await checkConsumptionLimits(consumptionDeps.agentId, consumptionDeps.limits);
240
- if (!limitResult.allowed) {
241
- trySend(ws, {
242
- type: 'review_rejected',
243
- id: crypto.randomUUID(),
244
- timestamp: Date.now(),
245
- taskId: request.taskId,
246
- reason: limitResult.reason ?? 'consumption_limit_exceeded',
247
- });
248
- console.log(`Review rejected: ${limitResult.reason}`);
249
- return;
250
- }
251
- }
252
- try {
253
- const result = await executeReview({
254
- taskId: request.taskId,
255
- diffContent: request.diffContent,
256
- prompt: request.project.prompt,
257
- owner: request.project.owner,
258
- repo: request.project.repo,
259
- prNumber: request.pr.number,
260
- timeout: request.timeout,
261
- reviewMode: request.reviewMode ?? 'full',
262
- }, reviewDeps);
263
- trySend(ws, {
264
- type: 'review_complete',
265
- id: crypto.randomUUID(),
266
- timestamp: Date.now(),
267
- taskId: request.taskId,
268
- review: result.review,
269
- verdict: result.verdict,
270
- tokensUsed: result.tokensUsed,
271
- });
272
- await logPostReviewStats('Review', result.verdict, result.tokensUsed, result.tokensEstimated, consumptionDeps);
273
- }
274
- catch (err) {
275
- if (err instanceof DiffTooLargeError) {
276
- trySend(ws, {
277
- type: 'review_rejected',
278
- id: crypto.randomUUID(),
279
- timestamp: Date.now(),
280
- taskId: request.taskId,
281
- reason: err.message,
282
- });
283
- }
284
- else {
285
- trySend(ws, {
286
- type: 'review_error',
287
- id: crypto.randomUUID(),
288
- timestamp: Date.now(),
289
- taskId: request.taskId,
290
- error: err instanceof Error ? err.message : 'Unknown error',
291
- });
292
- }
293
- console.error('Review failed:', err);
294
- }
295
- })();
296
- break;
297
- }
298
- case 'summary_request': {
299
- const summaryRequest = msg;
300
- console.log(`Summary request: task ${summaryRequest.taskId} for ${summaryRequest.project.owner}/${summaryRequest.project.repo}#${summaryRequest.pr.number} (${summaryRequest.reviews.length} reviews)`);
301
- if (!reviewDeps) {
302
- trySend(ws, {
303
- type: 'review_rejected',
304
- id: crypto.randomUUID(),
305
- timestamp: Date.now(),
306
- taskId: summaryRequest.taskId,
307
- reason: 'Review tool not configured',
308
- });
309
- break;
310
- }
311
- void (async () => {
312
- // Check consumption limits before executing
313
- if (consumptionDeps) {
314
- const limitResult = await checkConsumptionLimits(consumptionDeps.agentId, consumptionDeps.limits);
315
- if (!limitResult.allowed) {
316
- trySend(ws, {
317
- type: 'review_rejected',
318
- id: crypto.randomUUID(),
319
- timestamp: Date.now(),
320
- taskId: summaryRequest.taskId,
321
- reason: limitResult.reason ?? 'consumption_limit_exceeded',
322
- });
323
- console.log(`Summary rejected: ${limitResult.reason}`);
324
- return;
325
- }
326
- }
327
- try {
328
- const result = await executeSummary({
329
- taskId: summaryRequest.taskId,
330
- reviews: summaryRequest.reviews,
331
- prompt: summaryRequest.project.prompt,
332
- owner: summaryRequest.project.owner,
333
- repo: summaryRequest.project.repo,
334
- prNumber: summaryRequest.pr.number,
335
- timeout: summaryRequest.timeout,
336
- diffContent: summaryRequest.diffContent ?? '',
337
- }, reviewDeps);
338
- trySend(ws, {
339
- type: 'summary_complete',
340
- id: crypto.randomUUID(),
341
- timestamp: Date.now(),
342
- taskId: summaryRequest.taskId,
343
- summary: result.summary,
344
- tokensUsed: result.tokensUsed,
345
- });
346
- await logPostReviewStats('Summary', undefined, result.tokensUsed, result.tokensEstimated, consumptionDeps);
347
- }
348
- catch (err) {
349
- if (err instanceof InputTooLargeError) {
350
- trySend(ws, {
351
- type: 'review_rejected',
352
- id: crypto.randomUUID(),
353
- timestamp: Date.now(),
354
- taskId: summaryRequest.taskId,
355
- reason: err.message,
356
- });
357
- }
358
- else {
359
- trySend(ws, {
360
- type: 'review_error',
361
- id: crypto.randomUUID(),
362
- timestamp: Date.now(),
363
- taskId: summaryRequest.taskId,
364
- error: err instanceof Error ? err.message : 'Summary failed',
365
- });
366
- }
367
- console.error('Summary failed:', err);
368
- }
369
- })();
370
- break;
371
- }
372
- case 'error':
373
- console.error(`Platform error: ${msg.code ?? 'unknown'}`);
374
- if (msg.code === 'auth_revoked')
375
- process.exit(1);
376
- break;
377
- default:
378
- break;
379
- }
380
- }
381
- /** Sync a local agent to the server: find existing by model+tool or create new */
382
- async function syncAgentToServer(client, serverAgents, localAgent) {
383
- const existing = serverAgents.find((a) => a.model === localAgent.model && a.tool === localAgent.tool);
384
- if (existing) {
385
- return { agentId: existing.id, created: false };
386
- }
387
- const body = { model: localAgent.model, tool: localAgent.tool };
388
- if (localAgent.repos) {
389
- body.repoConfig = localAgent.repos;
390
- }
391
- const created = await client.post('/api/agents', body);
392
- return { agentId: created.id, created: true };
393
- }
394
- /** Resolve the effective command template for a local agent */
395
- function resolveLocalAgentCommand(localAgent, globalAgentCommand) {
396
- const effectiveCommand = localAgent.command ?? globalAgentCommand;
397
- return resolveCommandTemplate(effectiveCommand);
398
- }
399
- export { syncAgentToServer, resolveLocalAgentCommand };
400
- export const agentCommand = new Command('agent').description('Manage review agents');
401
- agentCommand
402
- .command('create')
403
- .description('Add an agent to local config (interactive or via flags)')
404
- .option('--model <model>', 'AI model name (e.g., claude-opus-4-6)')
405
- .option('--tool <tool>', 'Review tool name (e.g., claude-code)')
406
- .option('--command <cmd>', 'Custom command template (bypasses registry lookup)')
407
- .action(async (opts) => {
408
- const config = loadConfig();
409
- requireApiKey(config);
410
- let model;
411
- let tool;
412
- let command = opts.command;
413
- if (opts.model && opts.tool) {
414
- // Non-interactive mode
415
- model = opts.model;
416
- tool = opts.tool;
417
- }
418
- else if (opts.model || opts.tool) {
419
- console.error('Both --model and --tool are required in non-interactive mode.');
420
- process.exit(1);
421
- }
422
- else {
423
- // Interactive mode: fetch registry and prompt
424
- const client = new ApiClient(config.platformUrl, config.apiKey);
425
- let registry;
426
- try {
427
- registry = await client.get('/api/registry');
428
- }
429
- catch {
430
- console.warn('Could not fetch registry from server. Using built-in defaults.');
431
- registry = DEFAULT_REGISTRY;
432
- }
433
- const { search, input } = await import('@inquirer/prompts');
434
- const searchTheme = {
435
- style: {
436
- keysHelpTip: (keys) => keys.map(([key, action]) => `${key} ${action}`).join(', ') + ', ^C exit',
437
- },
438
- };
439
- const existingAgents = config.agents ?? [];
440
- const toolChoices = registry.tools.map((t) => ({
441
- name: t.displayName,
442
- value: t.name,
443
- }));
444
- try {
445
- // Loop: select tool → select model → check duplicate → if dup, restart
446
- while (true) {
447
- // Step 1: Select tool
448
- tool = await search({
449
- message: 'Select a tool:',
450
- theme: searchTheme,
451
- source: (term) => {
452
- const q = (term ?? '').toLowerCase();
453
- return toolChoices.filter((c) => c.name.toLowerCase().includes(q) || c.value.toLowerCase().includes(q));
454
- },
455
- });
456
- // Step 2: Select model — compatible first, others dimmed
457
- const compatible = registry.models.filter((m) => m.tools.includes(tool));
458
- const incompatible = registry.models.filter((m) => !m.tools.includes(tool));
459
- const modelChoices = [
460
- ...compatible.map((m) => ({
461
- name: m.displayName,
462
- value: m.name,
463
- })),
464
- ...incompatible.map((m) => ({
465
- name: `\x1b[38;5;249m${m.displayName}\x1b[0m`,
466
- value: m.name,
467
- })),
468
- ];
469
- model = await search({
470
- message: 'Select a model:',
471
- theme: searchTheme,
472
- source: (term) => {
473
- const q = (term ?? '').toLowerCase();
474
- return modelChoices.filter((c) => c.value.toLowerCase().includes(q) || c.name.toLowerCase().includes(q));
475
- },
476
- });
477
- // Check duplicate before proceeding
478
- const isDup = existingAgents.some((a) => a.model === model && a.tool === tool);
479
- if (isDup) {
480
- console.warn(`"${model}" / "${tool}" already exists in config. Choose again.`);
481
- continue;
482
- }
483
- // Warn if model isn't compatible with selected tool
484
- const modelEntry = registry.models.find((m) => m.name === model);
485
- if (modelEntry && !modelEntry.tools.includes(tool)) {
486
- console.warn(`Warning: "${model}" is not listed as compatible with "${tool}".`);
487
- }
488
- break;
489
- }
490
- // Step 3: Resolve default command and let user edit it
491
- const toolEntry = registry.tools.find((t) => t.name === tool);
492
- const defaultCommand = toolEntry
493
- ? toolEntry.commandTemplate.replaceAll('${MODEL}', model)
494
- : `${tool} --model ${model} -p \${PROMPT}`;
495
- command = await input({
496
- message: 'Command:',
497
- default: defaultCommand,
498
- prefill: 'editable',
499
- });
500
- }
501
- catch (err) {
502
- if (err && typeof err === 'object' && 'name' in err && err.name === 'ExitPromptError') {
503
- console.log('Cancelled.');
504
- return;
505
- }
506
- throw err;
507
- }
508
- }
509
- // Resolve command from registry if not set (non-interactive mode)
510
- if (!command) {
511
- const toolEntry = DEFAULT_REGISTRY.tools.find((t) => t.name === tool);
512
- if (toolEntry) {
513
- command = toolEntry.commandTemplate.replaceAll('${MODEL}', model);
514
- }
515
- else {
516
- console.error(`No command template for tool "${tool}". Use --command to specify one.`);
517
- process.exit(1);
518
- }
519
- }
520
- // Validate binary
521
- if (validateCommandBinary(command)) {
522
- console.log(`Verifying... binary found.`);
523
- }
524
- else {
525
- console.warn(`Warning: binary for command "${command.split(' ')[0]}" not found on this machine.`);
526
- }
527
- // Write to local config
528
- const newAgent = { model, tool, command };
529
- if (config.agents === null) {
530
- config.agents = [];
531
- }
532
- const isDuplicate = config.agents.some((a) => a.model === model && a.tool === tool);
533
- if (isDuplicate) {
534
- console.error(`Agent with model "${model}" and tool "${tool}" already exists in config.`);
535
- process.exit(1);
536
- }
537
- config.agents.push(newAgent);
538
- saveConfig(config);
539
- console.log('Agent added to config:');
540
- console.log(` Model: ${model}`);
541
- console.log(` Tool: ${tool}`);
542
- console.log(` Command: ${command}`);
543
- });
544
- agentCommand
545
- .command('init')
546
- .description('Import server-side agents into local config')
547
- .action(async () => {
548
- const config = loadConfig();
549
- const apiKey = requireApiKey(config);
550
- const client = new ApiClient(config.platformUrl, apiKey);
551
- let res;
552
- try {
553
- res = await client.get('/api/agents');
554
- }
555
- catch (err) {
556
- console.error('Failed to list agents:', err instanceof Error ? err.message : err);
557
- process.exit(1);
558
- }
559
- if (res.agents.length === 0) {
560
- console.log('No server-side agents found. Use `opencara agent create` to add one.');
561
- return;
562
- }
563
- // Fetch registry for command templates
564
- let registry;
565
- try {
566
- registry = await client.get('/api/registry');
567
- }
568
- catch {
569
- registry = DEFAULT_REGISTRY;
570
- }
571
- const toolCommands = new Map(registry.tools.map((t) => [t.name, t.commandTemplate]));
572
- const existing = config.agents ?? [];
573
- let imported = 0;
574
- for (const agent of res.agents) {
575
- const isDuplicate = existing.some((e) => e.model === agent.model && e.tool === agent.tool);
576
- if (isDuplicate)
577
- continue;
578
- let command = toolCommands.get(agent.tool);
579
- if (command) {
580
- command = command.replaceAll('${MODEL}', agent.model);
581
- }
582
- else {
583
- console.warn(`Warning: no command template for ${agent.model}/${agent.tool} — set command manually in config`);
584
- }
585
- existing.push({ model: agent.model, tool: agent.tool, command });
586
- imported++;
587
- }
588
- config.agents = existing;
589
- saveConfig(config);
590
- console.log(`Imported ${imported} agent(s) to local config.`);
591
- if (imported > 0) {
592
- console.log('Edit ~/.opencara/config.yml to adjust commands for your system.');
593
- }
594
- });
595
- agentCommand
596
- .command('list')
597
- .description('List registered agents')
598
- .action(async () => {
599
- const config = loadConfig();
600
- const apiKey = requireApiKey(config);
601
- const client = new ApiClient(config.platformUrl, apiKey);
602
- let res;
603
- try {
604
- res = await client.get('/api/agents');
605
- }
606
- catch (err) {
607
- console.error('Failed to list agents:', err instanceof Error ? err.message : err);
608
- process.exit(1);
609
- }
610
- // Fetch trust tier labels for each agent
611
- const trustLabels = new Map();
612
- for (const agent of res.agents) {
613
- try {
614
- const stats = await client.get(`/api/stats/${agent.id}`);
615
- trustLabels.set(agent.id, stats.agent.trustTier.label);
616
- }
617
- catch {
618
- // Leave as '--' if stats unavailable
619
- }
620
- }
621
- formatTable(res.agents, trustLabels);
622
- });
623
- /** Register or reuse an anonymous agent, returning credentials and command template */
624
- async function resolveAnonymousAgent(config, model, tool) {
625
- // Check for stored anonymous agent with matching model+tool
626
- const existing = config.anonymousAgents.find((a) => a.model === model && a.tool === tool);
627
- if (existing) {
628
- console.log(`Reusing stored anonymous agent ${existing.agentId} (${model} / ${tool})`);
629
- const command = resolveCommandTemplate(DEFAULT_REGISTRY.tools
630
- .find((t) => t.name === tool)
631
- ?.commandTemplate.replaceAll('${MODEL}', model) ?? null);
632
- return { entry: existing, command };
633
- }
634
- // Register new anonymous agent
635
- console.log('Registering anonymous agent...');
636
- const client = new ApiClient(config.platformUrl);
637
- const body = { model, tool };
638
- const res = await client.post('/api/agents/anonymous', body);
639
- const entry = {
640
- agentId: res.agentId,
641
- apiKey: res.apiKey,
642
- model,
643
- tool,
644
- };
645
- // Store in config
646
- config.anonymousAgents.push(entry);
647
- saveConfig(config);
648
- console.log(`Agent registered: ${res.agentId} (${model} / ${tool})`);
649
- console.log('Credentials saved to ~/.opencara/config.yml');
650
- const command = resolveCommandTemplate(DEFAULT_REGISTRY.tools
651
- .find((t) => t.name === tool)
652
- ?.commandTemplate.replaceAll('${MODEL}', model) ?? null);
653
- return { entry, command };
654
- }
655
- export { resolveAnonymousAgent };
656
- agentCommand
657
- .command('start [agentIdOrModel]')
658
- .description('Connect agent to platform via WebSocket')
659
- .option('--all', 'Start all agents from local config concurrently')
660
- .option('-a, --anonymous', 'Start an anonymous agent (no login required)')
661
- .option('--model <model>', 'AI model name (used with --anonymous)')
662
- .option('--tool <tool>', 'Review tool name (used with --anonymous)')
663
- .option('--verbose', 'Enable detailed WebSocket diagnostic logging')
664
- .option('--stability-threshold <ms>', `Connection stability threshold in ms (${STABILITY_THRESHOLD_MIN_MS}–${STABILITY_THRESHOLD_MAX_MS}, default: ${CONNECTION_STABILITY_THRESHOLD_MS})`)
665
- .action(async (agentIdOrModel, opts) => {
666
- let stabilityThresholdMs;
667
- if (opts.stabilityThreshold !== undefined) {
668
- const val = Number(opts.stabilityThreshold);
669
- if (!Number.isInteger(val) ||
670
- val < STABILITY_THRESHOLD_MIN_MS ||
671
- val > STABILITY_THRESHOLD_MAX_MS) {
672
- console.error(`Invalid --stability-threshold: must be an integer between ${STABILITY_THRESHOLD_MIN_MS} and ${STABILITY_THRESHOLD_MAX_MS}`);
673
- process.exit(1);
674
- }
675
- stabilityThresholdMs = val;
676
- }
677
- const config = loadConfig();
678
- // === Path C: Anonymous mode ===
679
- if (opts.anonymous) {
680
- if (!opts.model || !opts.tool) {
681
- console.error('Both --model and --tool are required with --anonymous.');
682
- process.exit(1);
683
- }
684
- let resolved;
685
- try {
686
- resolved = await resolveAnonymousAgent(config, opts.model, opts.tool);
687
- }
688
- catch (err) {
689
- console.error('Failed to register anonymous agent:', err instanceof Error ? err.message : err);
690
- process.exit(1);
691
- }
692
- const { entry, command } = resolved;
693
- let reviewDeps;
694
- if (validateCommandBinary(command)) {
695
- reviewDeps = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
696
- }
697
- else {
698
- console.warn(`Warning: binary "${command.split(' ')[0]}" not found. Reviews will be rejected.`);
699
- }
700
- const consumptionDeps = {
701
- agentId: entry.agentId,
702
- limits: config.limits,
703
- session: createSessionTracker(),
704
- };
705
- console.log(`Starting anonymous agent ${entry.agentId}...`);
706
- startAgent(entry.agentId, config.platformUrl, entry.apiKey, reviewDeps, consumptionDeps, {
707
- verbose: opts.verbose,
708
- stabilityThresholdMs,
709
- repoConfig: entry.repoConfig,
710
- });
711
- return;
712
- }
713
- // === Path B: Local-config mode (agents section exists) ===
714
- if (config.agents !== null) {
715
- // Validate and filter agents by binary availability
716
- const validAgents = [];
717
- for (const local of config.agents) {
718
- let cmd;
719
- try {
720
- cmd = resolveLocalAgentCommand(local, config.agentCommand);
721
- }
722
- catch (err) {
723
- console.warn(`Skipping ${local.model}/${local.tool}: ${err instanceof Error ? err.message : 'no command template available'}`);
724
- continue;
725
- }
726
- if (!validateCommandBinary(cmd)) {
727
- console.warn(`Skipping ${local.model}/${local.tool}: binary "${cmd.split(' ')[0]}" not found`);
728
- continue;
729
- }
730
- validAgents.push({ local, command: cmd });
731
- }
732
- if (validAgents.length === 0 && config.anonymousAgents.length === 0) {
733
- console.error('No valid agents in config. Check that tool binaries are installed.');
734
- process.exit(1);
735
- }
736
- // Determine which agents to start
737
- let agentsToStart;
738
- const anonAgentsToStart = [];
739
- if (opts.all) {
740
- agentsToStart = validAgents;
741
- // Also include anonymous agents when --all is used
742
- anonAgentsToStart.push(...config.anonymousAgents);
743
- }
744
- else if (agentIdOrModel) {
745
- const match = validAgents.find((a) => a.local.model === agentIdOrModel);
746
- if (!match) {
747
- console.error(`No agent with model "${agentIdOrModel}" found in local config.`);
748
- console.error('Available agents:');
749
- for (const a of validAgents) {
750
- console.error(` ${a.local.model} (${a.local.tool})`);
751
- }
752
- process.exit(1);
753
- }
754
- agentsToStart = [match];
755
- }
756
- else if (validAgents.length === 1) {
757
- agentsToStart = [validAgents[0]];
758
- console.log(`Using agent ${validAgents[0].local.model} (${validAgents[0].local.tool})`);
759
- }
760
- else if (validAgents.length === 0) {
761
- console.error('No valid authenticated agents in config. Use --anonymous or --all.');
762
- process.exit(1);
763
- }
764
- else {
765
- console.error('Multiple agents in config. Specify a model name or use --all:');
766
- for (const a of validAgents) {
767
- console.error(` ${a.local.model} (${a.local.tool})`);
768
- }
769
- process.exit(1);
770
- }
771
- // Increase listener limit when starting multiple agents
772
- const totalAgents = agentsToStart.length + anonAgentsToStart.length;
773
- if (totalAgents > 1) {
774
- process.setMaxListeners(process.getMaxListeners() + totalAgents * 2);
775
- }
776
- // Start each authenticated agent (requires login)
777
- let startedCount = 0;
778
- let apiKey;
779
- let client;
780
- let serverAgents;
781
- if (agentsToStart.length > 0) {
782
- apiKey = requireApiKey(config);
783
- client = new ApiClient(config.platformUrl, apiKey);
784
- try {
785
- const res = await client.get('/api/agents');
786
- serverAgents = res.agents;
787
- }
788
- catch (err) {
789
- console.error('Failed to fetch agents:', err instanceof Error ? err.message : err);
790
- process.exit(1);
791
- }
792
- }
793
- for (const selected of agentsToStart) {
794
- let agentId;
795
- try {
796
- const sync = await syncAgentToServer(client, serverAgents, selected.local);
797
- agentId = sync.agentId;
798
- if (sync.created) {
799
- console.log(`Registered new agent ${agentId} on platform`);
800
- // Update snapshot to prevent duplicate registrations
801
- serverAgents.push({
802
- id: agentId,
803
- model: selected.local.model,
804
- tool: selected.local.tool,
805
- isAnonymous: false,
806
- status: 'offline',
807
- repoConfig: null,
808
- createdAt: new Date().toISOString(),
809
- });
810
- }
811
- }
812
- catch (err) {
813
- console.error(`Failed to sync agent ${selected.local.model} to server:`, err instanceof Error ? err.message : err);
814
- continue;
815
- }
816
- const reviewDeps = {
817
- commandTemplate: selected.command,
818
- maxDiffSizeKb: config.maxDiffSizeKb,
819
- };
820
- const consumptionDeps = {
821
- agentId,
822
- limits: resolveAgentLimits(selected.local.limits, config.limits),
823
- session: createSessionTracker(),
824
- };
825
- console.log(`Starting agent ${selected.local.model} (${agentId})...`);
826
- startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
827
- verbose: opts.verbose,
828
- stabilityThresholdMs,
829
- repoConfig: selected.local.repos,
830
- });
831
- startedCount++;
832
- }
833
- // Start anonymous agents (for --all)
834
- for (const anon of anonAgentsToStart) {
835
- let command;
836
- try {
837
- command = resolveCommandTemplate(DEFAULT_REGISTRY.tools
838
- .find((t) => t.name === anon.tool)
839
- ?.commandTemplate.replaceAll('${MODEL}', anon.model) ?? null);
840
- }
841
- catch {
842
- console.warn(`Skipping anonymous agent ${anon.agentId}: no command template for tool "${anon.tool}"`);
843
- continue;
844
- }
845
- let reviewDeps;
846
- if (validateCommandBinary(command)) {
847
- reviewDeps = { commandTemplate: command, maxDiffSizeKb: config.maxDiffSizeKb };
848
- }
849
- else {
850
- console.warn(`Warning: binary "${command.split(' ')[0]}" not found for anonymous agent ${anon.agentId}. Reviews will be rejected.`);
851
- }
852
- const consumptionDeps = {
853
- agentId: anon.agentId,
854
- limits: config.limits,
855
- session: createSessionTracker(),
856
- };
857
- console.log(`Starting anonymous agent ${anon.model} (${anon.agentId})...`);
858
- startAgent(anon.agentId, config.platformUrl, anon.apiKey, reviewDeps, consumptionDeps, {
859
- verbose: opts.verbose,
860
- stabilityThresholdMs,
861
- repoConfig: anon.repoConfig,
862
- });
863
- startedCount++;
864
- }
865
- if (startedCount === 0) {
866
- console.error('No agents could be started.');
867
- process.exit(1);
868
- }
869
- return;
870
- }
871
- // === Path A: Old server-side behavior (no agents section) ===
872
- const apiKey = requireApiKey(config);
873
- const client = new ApiClient(config.platformUrl, apiKey);
874
- console.log('Hint: No agents in local config. Run `opencara agent init` to import, or `opencara agent create` to add agents.');
875
- let agentId = agentIdOrModel;
876
- if (!agentId) {
877
- let res;
878
- try {
879
- res = await client.get('/api/agents');
880
- }
881
- catch (err) {
882
- console.error('Failed to list agents:', err instanceof Error ? err.message : err);
883
- process.exit(1);
884
- }
885
- if (res.agents.length === 0) {
886
- console.error('No agents registered. Run `opencara agent create` first.');
887
- process.exit(1);
888
- }
889
- if (res.agents.length === 1) {
890
- agentId = res.agents[0].id;
891
- console.log(`Using agent ${agentId}`);
892
- }
893
- else {
894
- console.error('Multiple agents found. Please specify an agent ID:');
895
- for (const a of res.agents) {
896
- console.error(` ${a.id} ${a.model} / ${a.tool}`);
897
- }
898
- process.exit(1);
899
- }
900
- }
901
- let reviewDeps;
902
- try {
903
- const commandTemplate = resolveCommandTemplate(config.agentCommand);
904
- reviewDeps = {
905
- commandTemplate,
906
- maxDiffSizeKb: config.maxDiffSizeKb,
907
- };
908
- }
909
- catch (err) {
910
- console.warn(`Warning: ${err instanceof Error ? err.message : 'Could not determine agent command.'}` +
911
- ' Reviews will be rejected.');
912
- }
913
- const consumptionDeps = {
914
- agentId,
915
- limits: config.limits,
916
- session: createSessionTracker(),
917
- };
918
- console.log(`Starting agent ${agentId}...`);
919
- startAgent(agentId, config.platformUrl, apiKey, reviewDeps, consumptionDeps, {
920
- verbose: opts.verbose,
921
- stabilityThresholdMs,
922
- });
923
- });
924
- //# sourceMappingURL=agent.js.map