opencara 0.11.0 → 0.12.1

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 (3) hide show
  1. package/README.md +126 -0
  2. package/dist/index.js +450 -87
  3. package/package.json +29 -5
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # opencara
2
+
3
+ Distributed AI code review agent for GitHub pull requests. Run review agents locally using your own AI tools and API keys — OpenCara never touches your credentials.
4
+
5
+ ## How It Works
6
+
7
+ ```
8
+ GitHub PR → OpenCara server creates review task
9
+ → Your agent polls for tasks → Claims one → Fetches diff from GitHub
10
+ → Reviews locally with your AI tool → Submits result
11
+ → Server posts the review to the PR
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```bash
17
+ # 1. Install
18
+ npm i -g opencara
19
+
20
+ # 2. Create config
21
+ mkdir -p ~/.opencara
22
+ cat > ~/.opencara/config.yml << 'EOF'
23
+ platform_url: https://opencara-server.opencara.workers.dev
24
+ agents:
25
+ - model: claude-sonnet-4-6
26
+ tool: claude
27
+ command: claude --model claude-sonnet-4-6 --allowedTools '*' --print
28
+ EOF
29
+
30
+ # 3. Start
31
+ opencara agent start
32
+ ```
33
+
34
+ Your agent is now polling for review tasks every 10 seconds.
35
+
36
+ ## Supported AI Tools
37
+
38
+ | Tool | Install | Example models |
39
+ | ---------- | ------------------------------------ | ---------------------------------- |
40
+ | Claude | `npm i -g @anthropic-ai/claude-code` | claude-sonnet-4-6, claude-opus-4-6 |
41
+ | Codex | `npm i -g @openai/codex` | gpt-5.4-codex |
42
+ | Gemini CLI | `npm i -g @google/gemini-cli` | gemini-2.5-pro, gemini-2.5-flash |
43
+ | Qwen CLI | `npm i -g qwen` | qwen3.5-plus, glm-5, kimi-k2.5 |
44
+
45
+ Each tool requires its own API key configured per its documentation.
46
+
47
+ ## Configuration
48
+
49
+ Edit `~/.opencara/config.yml`:
50
+
51
+ ```yaml
52
+ platform_url: https://opencara-server.opencara.workers.dev
53
+
54
+ agents:
55
+ - model: claude-sonnet-4-6
56
+ tool: claude
57
+ command: claude --model claude-sonnet-4-6 --allowedTools '*' --print
58
+
59
+ - model: gemini-2.5-pro
60
+ tool: gemini
61
+ command: gemini -m gemini-2.5-pro
62
+ ```
63
+
64
+ Review prompts are delivered via **stdin** to your command. The command reads stdin, processes it with the AI model, and writes the review to stdout.
65
+
66
+ ### Agent Config Fields
67
+
68
+ | Field | Required | Default | Description |
69
+ | -------------- | -------- | ------- | ------------------------------------------------------ |
70
+ | `model` | Yes | -- | AI model identifier (e.g., `claude-sonnet-4-6`) |
71
+ | `tool` | Yes | -- | AI tool identifier (e.g., `claude`, `codex`, `gemini`) |
72
+ | `command` | Yes\* | -- | Shell command to execute reviews (stdin -> stdout) |
73
+ | `name` | No | -- | Display name in CLI logs (local only) |
74
+ | `review_only` | No | `false` | If `true`, agent only reviews, never synthesizes |
75
+ | `github_token` | No | -- | Per-agent GitHub token for private repos |
76
+ | `codebase_dir` | No | -- | Local clone directory for context-aware reviews |
77
+ | `repos` | No | -- | Repo filtering (mode: all/own/whitelist/blacklist) |
78
+
79
+ \*Required unless `agent_command` is set globally.
80
+
81
+ ### Global Config Fields
82
+
83
+ | Field | Default | Description |
84
+ | ------------------------ | -------------------------- | ------------------------------------- |
85
+ | `platform_url` | `https://api.opencara.dev` | OpenCara server URL |
86
+ | `github_token` | -- | GitHub token for private repo diffs |
87
+ | `codebase_dir` | -- | Default clone directory for repos |
88
+ | `max_diff_size_kb` | `100` | Skip PRs with diffs larger than this |
89
+ | `max_consecutive_errors` | `10` | Stop agent after N consecutive errors |
90
+
91
+ ## CLI Reference
92
+
93
+ ### Commands
94
+
95
+ | Command | Description |
96
+ | ---------------------- | ----------------------------------------------- |
97
+ | `opencara` | Start agent in router mode (stdin/stdout relay) |
98
+ | `opencara agent start` | Start an agent in polling mode |
99
+
100
+ ### `opencara agent start` Options
101
+
102
+ | Option | Default | Description |
103
+ | --------------------------- | ------- | ---------------------------------------- |
104
+ | `--agent <index>` | `0` | Agent index from config.yml (0-based) |
105
+ | `--all` | -- | Start all configured agents concurrently |
106
+ | `--poll-interval <seconds>` | `10` | Poll interval in seconds |
107
+
108
+ ## Environment Variables
109
+
110
+ | Variable | Description |
111
+ | ----------------------- | ------------------------------------------------------------------ |
112
+ | `OPENCARA_CONFIG` | Path to alternate config file (overrides `~/.opencara/config.yml`) |
113
+ | `OPENCARA_PLATFORM_URL` | Override the platform URL from config |
114
+ | `GITHUB_TOKEN` | GitHub token (fallback for private repo access) |
115
+
116
+ ## Private Repos
117
+
118
+ The CLI resolves GitHub tokens using a fallback chain:
119
+
120
+ 1. `GITHUB_TOKEN` environment variable
121
+ 2. `gh auth token` (if GitHub CLI is installed)
122
+ 3. `github_token` in config.yml (global or per-agent)
123
+
124
+ ## License
125
+
126
+ MIT
package/dist/index.js CHANGED
@@ -28,6 +28,84 @@ function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
28
28
  }
29
29
  }
30
30
 
31
+ // ../shared/dist/api.js
32
+ var DEFAULT_REGISTRY = {
33
+ tools: [
34
+ {
35
+ name: "claude",
36
+ displayName: "Claude",
37
+ binary: "claude",
38
+ commandTemplate: "claude --model ${MODEL} --allowedTools '*' --print",
39
+ tokenParser: "claude"
40
+ },
41
+ {
42
+ name: "codex",
43
+ displayName: "Codex",
44
+ binary: "codex",
45
+ commandTemplate: "codex --model ${MODEL} exec",
46
+ tokenParser: "codex"
47
+ },
48
+ {
49
+ name: "gemini",
50
+ displayName: "Gemini",
51
+ binary: "gemini",
52
+ commandTemplate: "gemini -m ${MODEL}",
53
+ tokenParser: "gemini"
54
+ },
55
+ {
56
+ name: "qwen",
57
+ displayName: "Qwen",
58
+ binary: "qwen",
59
+ commandTemplate: "qwen --model ${MODEL} -y",
60
+ tokenParser: "qwen"
61
+ }
62
+ ],
63
+ models: [
64
+ {
65
+ name: "claude-opus-4-6",
66
+ displayName: "Claude Opus 4.6",
67
+ tools: ["claude"]
68
+ },
69
+ {
70
+ name: "claude-opus-4-6[1m]",
71
+ displayName: "Claude Opus 4.6 (1M context)",
72
+ tools: ["claude"]
73
+ },
74
+ {
75
+ name: "claude-sonnet-4-6",
76
+ displayName: "Claude Sonnet 4.6",
77
+ tools: ["claude"]
78
+ },
79
+ {
80
+ name: "claude-sonnet-4-6[1m]",
81
+ displayName: "Claude Sonnet 4.6 (1M context)",
82
+ tools: ["claude"]
83
+ },
84
+ {
85
+ name: "gpt-5-codex",
86
+ displayName: "GPT-5 Codex",
87
+ tools: ["codex"]
88
+ },
89
+ {
90
+ name: "gemini-2.5-pro",
91
+ displayName: "Gemini 2.5 Pro",
92
+ tools: ["gemini"]
93
+ },
94
+ {
95
+ name: "qwen3.5-plus",
96
+ displayName: "Qwen 3.5 Plus",
97
+ tools: ["qwen"]
98
+ },
99
+ { name: "glm-5", displayName: "GLM-5", tools: ["qwen"] },
100
+ { name: "kimi-k2.5", displayName: "Kimi K2.5", tools: ["qwen"] },
101
+ {
102
+ name: "minimax-m2.5",
103
+ displayName: "Minimax M2.5",
104
+ tools: ["qwen"]
105
+ }
106
+ ]
107
+ };
108
+
31
109
  // ../shared/dist/review-config.js
32
110
  import { parse as parseYaml } from "yaml";
33
111
 
@@ -49,6 +127,16 @@ var RepoConfigError = class extends Error {
49
127
  this.name = "RepoConfigError";
50
128
  }
51
129
  };
130
+ var ConfigValidationError = class extends Error {
131
+ constructor(message) {
132
+ super(message);
133
+ this.name = "ConfigValidationError";
134
+ }
135
+ };
136
+ var KNOWN_TOOL_NAMES = new Set(DEFAULT_REGISTRY.tools.map((t) => t.name));
137
+ var TOOL_ALIASES = {
138
+ "claude-code": "claude"
139
+ };
52
140
  function parseRepoConfig(obj, index) {
53
141
  const raw = obj.repos;
54
142
  if (raw === void 0 || raw === null) return void 0;
@@ -66,13 +154,15 @@ function parseRepoConfig(obj, index) {
66
154
  );
67
155
  }
68
156
  const config = { mode };
157
+ const list = reposObj.list;
69
158
  if (mode === "whitelist" || mode === "blacklist") {
70
- const list = reposObj.list;
71
159
  if (!Array.isArray(list) || list.length === 0) {
72
160
  throw new RepoConfigError(
73
161
  `agents[${index}].repos.list is required and must be non-empty for mode '${mode}'`
74
162
  );
75
163
  }
164
+ }
165
+ if (Array.isArray(list) && list.length > 0) {
76
166
  for (let j = 0; j < list.length; j++) {
77
167
  if (typeof list[j] !== "string" || !REPO_PATTERN.test(list[j])) {
78
168
  throw new RepoConfigError(
@@ -92,15 +182,33 @@ function parseAgents(data) {
92
182
  for (let i = 0; i < raw.length; i++) {
93
183
  const entry = raw[i];
94
184
  if (!entry || typeof entry !== "object") {
95
- console.warn(`Warning: agents[${i}] is not an object, skipping`);
185
+ console.warn(`\u26A0 Config warning: agents[${i}] is not an object, skipping agent`);
96
186
  continue;
97
187
  }
98
188
  const obj = entry;
99
189
  if (typeof obj.model !== "string" || typeof obj.tool !== "string") {
100
- console.warn(`Warning: agents[${i}] missing required model/tool fields, skipping`);
190
+ console.warn(
191
+ `\u26A0 Config warning: agents[${i}] missing required model/tool fields, skipping agent`
192
+ );
101
193
  continue;
102
194
  }
103
- const agent = { model: obj.model, tool: obj.tool };
195
+ let resolvedTool = obj.tool;
196
+ if (!KNOWN_TOOL_NAMES.has(resolvedTool)) {
197
+ const alias = TOOL_ALIASES[resolvedTool];
198
+ if (alias) {
199
+ console.warn(
200
+ `\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" is deprecated, using "${alias}" instead`
201
+ );
202
+ resolvedTool = alias;
203
+ } else {
204
+ const toolNames = [...KNOWN_TOOL_NAMES].join(", ");
205
+ console.warn(
206
+ `\u26A0 Config warning: agents[${i}].tool "${resolvedTool}" not in registry (known: ${toolNames}), skipping agent`
207
+ );
208
+ continue;
209
+ }
210
+ }
211
+ const agent = { model: obj.model, tool: resolvedTool };
104
212
  if (typeof obj.name === "string") agent.name = obj.name;
105
213
  if (typeof obj.command === "string") agent.command = obj.command;
106
214
  if (obj.router === true) agent.router = true;
@@ -113,6 +221,35 @@ function parseAgents(data) {
113
221
  }
114
222
  return agents;
115
223
  }
224
+ function isValidHttpUrl(value) {
225
+ try {
226
+ const url = new URL(value);
227
+ return url.protocol === "http:" || url.protocol === "https:";
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+ function validateConfigData(data, envPlatformUrl) {
233
+ const overrides = {};
234
+ if (!envPlatformUrl && typeof data.platform_url === "string" && !isValidHttpUrl(data.platform_url)) {
235
+ throw new ConfigValidationError(
236
+ `\u2717 Config error: platform_url "${data.platform_url}" is not a valid URL`
237
+ );
238
+ }
239
+ if (typeof data.max_diff_size_kb === "number" && data.max_diff_size_kb <= 0) {
240
+ console.warn(
241
+ `\u26A0 Config warning: max_diff_size_kb must be > 0, got ${data.max_diff_size_kb}, using default (${DEFAULT_MAX_DIFF_SIZE_KB})`
242
+ );
243
+ overrides.maxDiffSizeKb = DEFAULT_MAX_DIFF_SIZE_KB;
244
+ }
245
+ if (typeof data.max_consecutive_errors === "number" && data.max_consecutive_errors <= 0) {
246
+ console.warn(
247
+ `\u26A0 Config warning: max_consecutive_errors must be > 0, got ${data.max_consecutive_errors}, using default (${DEFAULT_MAX_CONSECUTIVE_ERRORS})`
248
+ );
249
+ overrides.maxConsecutiveErrors = DEFAULT_MAX_CONSECUTIVE_ERRORS;
250
+ }
251
+ return overrides;
252
+ }
116
253
  function loadConfig() {
117
254
  const envPlatformUrl = process.env.OPENCARA_PLATFORM_URL?.trim() || null;
118
255
  const defaults = {
@@ -132,10 +269,11 @@ function loadConfig() {
132
269
  if (!data || typeof data !== "object") {
133
270
  return defaults;
134
271
  }
272
+ const overrides = validateConfigData(data, envPlatformUrl);
135
273
  return {
136
274
  platformUrl: envPlatformUrl || (typeof data.platform_url === "string" ? data.platform_url : DEFAULT_PLATFORM_URL),
137
- maxDiffSizeKb: typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB,
138
- maxConsecutiveErrors: typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS,
275
+ maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
276
+ maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
139
277
  githubToken: typeof data.github_token === "string" ? data.github_token : null,
140
278
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
141
279
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
@@ -267,9 +405,10 @@ function logAuthMethod(method, log) {
267
405
 
268
406
  // src/http.ts
269
407
  var HttpError = class extends Error {
270
- constructor(status, message) {
408
+ constructor(status, message, errorCode) {
271
409
  super(message);
272
410
  this.status = status;
411
+ this.errorCode = errorCode;
273
412
  this.name = "HttpError";
274
413
  }
275
414
  };
@@ -307,13 +446,17 @@ var ApiClient = class {
307
446
  async handleResponse(res, path5) {
308
447
  if (!res.ok) {
309
448
  let message = `HTTP ${res.status}`;
449
+ let errorCode;
310
450
  try {
311
451
  const body = await res.json();
312
- if (body.error) message = body.error;
452
+ if (body.error && typeof body.error === "object" && "code" in body.error) {
453
+ errorCode = body.error.code;
454
+ message = body.error.message;
455
+ }
313
456
  } catch {
314
457
  }
315
458
  this.log(`${res.status} ${message} (${path5})`);
316
- throw new HttpError(res.status, message);
459
+ throw new HttpError(res.status, message, errorCode);
317
460
  }
318
461
  this.log(`${res.status} OK (${path5})`);
319
462
  return await res.json();
@@ -611,12 +754,13 @@ function buildSystemPrompt(owner, repo, mode = "full") {
611
754
  const template = mode === "compact" ? COMPACT_SYSTEM_PROMPT_TEMPLATE : FULL_SYSTEM_PROMPT_TEMPLATE;
612
755
  return template.replace("{owner}", owner).replace("{repo}", repo);
613
756
  }
614
- function buildUserMessage(prompt, diffContent) {
615
- return `${prompt}
616
-
617
- ---
618
-
619
- ${diffContent}`;
757
+ function buildUserMessage(prompt, diffContent, contextBlock) {
758
+ const parts = [prompt];
759
+ if (contextBlock) {
760
+ parts.push(contextBlock);
761
+ }
762
+ parts.push(diffContent);
763
+ return parts.join("\n\n---\n\n");
620
764
  }
621
765
  var SECTION_VERDICT_PATTERN = /##\s*Verdict\s*\n+\s*(APPROVE|REQUEST_CHANGES|COMMENT)\b/im;
622
766
  var LEGACY_VERDICT_PATTERN = /^VERDICT:\s*(APPROVE|REQUEST_CHANGES|COMMENT)\s*$/m;
@@ -656,7 +800,7 @@ async function executeReview(req, deps, runTool = executeTool) {
656
800
  }, effectiveTimeout);
657
801
  try {
658
802
  const systemPrompt = buildSystemPrompt(req.owner, req.repo, req.reviewMode);
659
- const userMessage = buildUserMessage(req.prompt, req.diffContent);
803
+ const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
660
804
  const fullPrompt = `${systemPrompt}
661
805
 
662
806
  ${userMessage}`;
@@ -728,27 +872,28 @@ For each finding, explain clearly what the problem is and how to fix it.
728
872
  ## Verdict
729
873
  APPROVE | REQUEST_CHANGES | COMMENT`;
730
874
  }
731
- function buildSummaryUserMessage(prompt, reviews, diffContent) {
875
+ function buildSummaryUserMessage(prompt, reviews, diffContent, contextBlock) {
732
876
  const reviewSections = reviews.map((r) => `### Review by ${r.model}/${r.tool} (Verdict: ${r.verdict})
733
877
  ${r.review}`).join("\n\n");
734
- return `Project review guidelines:
735
- ${prompt}
736
-
737
- ---
738
-
739
- Pull request diff:
740
-
741
- ${diffContent}
742
-
743
- ---
878
+ const parts = [`Project review guidelines:
879
+ ${prompt}`];
880
+ if (contextBlock) {
881
+ parts.push(contextBlock);
882
+ }
883
+ parts.push(`Pull request diff:
744
884
 
745
- Compact reviews from other agents:
885
+ ${diffContent}`);
886
+ parts.push(`Compact reviews from other agents:
746
887
 
747
- ${reviewSections}`;
888
+ ${reviewSections}`);
889
+ return parts.join("\n\n---\n\n");
748
890
  }
749
- function calculateInputSize(prompt, reviews, diffContent) {
891
+ function calculateInputSize(prompt, reviews, diffContent, contextBlock) {
750
892
  let size = Buffer.byteLength(prompt, "utf-8");
751
893
  size += Buffer.byteLength(diffContent, "utf-8");
894
+ if (contextBlock) {
895
+ size += Buffer.byteLength(contextBlock, "utf-8");
896
+ }
752
897
  for (const r of reviews) {
753
898
  size += Buffer.byteLength(r.review, "utf-8");
754
899
  size += Buffer.byteLength(r.model, "utf-8");
@@ -758,7 +903,7 @@ function calculateInputSize(prompt, reviews, diffContent) {
758
903
  return size;
759
904
  }
760
905
  async function executeSummary(req, deps, runTool = executeTool) {
761
- const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent);
906
+ const inputSize = calculateInputSize(req.prompt, req.reviews, req.diffContent, req.contextBlock);
762
907
  if (inputSize > MAX_INPUT_SIZE_BYTES) {
763
908
  throw new InputTooLargeError(
764
909
  `Summary input too large (${Math.round(inputSize / 1024)}KB > ${Math.round(MAX_INPUT_SIZE_BYTES / 1024)}KB limit)`
@@ -775,7 +920,12 @@ async function executeSummary(req, deps, runTool = executeTool) {
775
920
  }, effectiveTimeout);
776
921
  try {
777
922
  const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
778
- const userMessage = buildSummaryUserMessage(req.prompt, req.reviews, req.diffContent);
923
+ const userMessage = buildSummaryUserMessage(
924
+ req.prompt,
925
+ req.reviews,
926
+ req.diffContent,
927
+ req.contextBlock
928
+ );
779
929
  const fullPrompt = `${systemPrompt}
780
930
 
781
931
  ${userMessage}`;
@@ -874,7 +1024,7 @@ var RouterRelay = class {
874
1024
  req.repo,
875
1025
  req.reviewMode
876
1026
  );
877
- const userMessage = buildUserMessage(req.prompt, req.diffContent);
1027
+ const userMessage = buildUserMessage(req.prompt, req.diffContent, req.contextBlock);
878
1028
  return `${systemPrompt}
879
1029
 
880
1030
  ${userMessage}`;
@@ -882,7 +1032,12 @@ ${userMessage}`;
882
1032
  /** Build the full prompt for a summary request */
883
1033
  buildSummaryPrompt(req) {
884
1034
  const systemPrompt = buildSummarySystemPrompt(req.owner, req.repo, req.reviews.length);
885
- const userMessage = buildSummaryUserMessage(req.prompt, req.reviews, req.diffContent);
1035
+ const userMessage = buildSummaryUserMessage(
1036
+ req.prompt,
1037
+ req.reviews,
1038
+ req.diffContent,
1039
+ req.contextBlock
1040
+ );
886
1041
  return `${systemPrompt}
887
1042
 
888
1043
  ${userMessage}`;
@@ -961,18 +1116,196 @@ function formatPostReviewStats(session) {
961
1116
  return ` Session: ${session.tokens.toLocaleString()} tokens / ${session.reviews} reviews`;
962
1117
  }
963
1118
 
964
- // src/commands/agent.ts
965
- var DEFAULT_POLL_INTERVAL_MS = 1e4;
966
- var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
967
- var MAX_POLL_BACKOFF_MS = 3e5;
1119
+ // src/pr-context.ts
1120
+ async function githubGet(url, deps) {
1121
+ const headers = {
1122
+ Accept: "application/vnd.github+json"
1123
+ };
1124
+ if (deps.githubToken) {
1125
+ headers["Authorization"] = `Bearer ${deps.githubToken}`;
1126
+ }
1127
+ const response = await fetch(url, { headers, signal: deps.signal });
1128
+ if (!response.ok) {
1129
+ throw new Error(`GitHub API ${response.status}: ${response.statusText}`);
1130
+ }
1131
+ return response.json();
1132
+ }
1133
+ async function fetchPRMetadata(owner, repo, prNumber, deps) {
1134
+ try {
1135
+ const pr = await githubGet(
1136
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
1137
+ deps
1138
+ );
1139
+ return {
1140
+ title: pr.title,
1141
+ body: pr.body ?? "",
1142
+ author: pr.user?.login ?? "unknown",
1143
+ labels: pr.labels.map((l) => l.name),
1144
+ baseBranch: pr.base.ref,
1145
+ headBranch: pr.head.ref
1146
+ };
1147
+ } catch {
1148
+ return null;
1149
+ }
1150
+ }
1151
+ async function fetchIssueComments(owner, repo, prNumber, deps) {
1152
+ try {
1153
+ const comments = await githubGet(
1154
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
1155
+ deps
1156
+ );
1157
+ return comments.map((c) => ({
1158
+ author: c.user?.login ?? "unknown",
1159
+ body: c.body,
1160
+ createdAt: c.created_at
1161
+ }));
1162
+ } catch {
1163
+ return [];
1164
+ }
1165
+ }
1166
+ async function fetchReviewComments(owner, repo, prNumber, deps) {
1167
+ try {
1168
+ const comments = await githubGet(
1169
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments`,
1170
+ deps
1171
+ );
1172
+ return comments.map((c) => ({
1173
+ author: c.user?.login ?? "unknown",
1174
+ body: c.body,
1175
+ path: c.path,
1176
+ line: c.line,
1177
+ createdAt: c.created_at
1178
+ }));
1179
+ } catch {
1180
+ return [];
1181
+ }
1182
+ }
1183
+ async function fetchExistingReviews(owner, repo, prNumber, deps) {
1184
+ try {
1185
+ const reviews = await githubGet(
1186
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
1187
+ deps
1188
+ );
1189
+ return reviews.filter((r) => r.state !== "PENDING").map((r) => ({
1190
+ author: r.user?.login ?? "unknown",
1191
+ state: r.state,
1192
+ body: r.body ?? ""
1193
+ }));
1194
+ } catch {
1195
+ return [];
1196
+ }
1197
+ }
1198
+ async function fetchPRContext(owner, repo, prNumber, deps) {
1199
+ const [metadata, comments, reviewThreads, existingReviews] = await Promise.all([
1200
+ fetchPRMetadata(owner, repo, prNumber, deps),
1201
+ fetchIssueComments(owner, repo, prNumber, deps),
1202
+ fetchReviewComments(owner, repo, prNumber, deps),
1203
+ fetchExistingReviews(owner, repo, prNumber, deps)
1204
+ ]);
1205
+ return { metadata, comments, reviewThreads, existingReviews };
1206
+ }
1207
+ function formatPRContext(context, codebaseDir) {
1208
+ const sections = [];
1209
+ if (context.metadata) {
1210
+ const m = context.metadata;
1211
+ const lines = ["## PR Context", `**Title**: ${m.title}`, `**Author**: @${m.author}`];
1212
+ if (m.body) {
1213
+ lines.push(`**Description**: ${m.body}`);
1214
+ }
1215
+ if (m.labels.length > 0) {
1216
+ lines.push(`**Labels**: ${m.labels.join(", ")}`);
1217
+ }
1218
+ lines.push(`**Branches**: ${m.headBranch} \u2192 ${m.baseBranch}`);
1219
+ sections.push(lines.join("\n"));
1220
+ }
1221
+ if (context.comments.length > 0) {
1222
+ const commentLines = context.comments.map((c) => `@${c.author}: ${c.body}`);
1223
+ sections.push(
1224
+ `## Discussion (${context.comments.length} comment${context.comments.length === 1 ? "" : "s"})
1225
+ ${commentLines.join("\n")}`
1226
+ );
1227
+ }
1228
+ if (context.reviewThreads.length > 0) {
1229
+ const threadLines = context.reviewThreads.map((t) => {
1230
+ const location = t.line ? `\`${t.path}:${t.line}\`` : `\`${t.path}\``;
1231
+ return `@${t.author} on ${location}: ${t.body}`;
1232
+ });
1233
+ sections.push(`## Review Threads (${context.reviewThreads.length})
1234
+ ${threadLines.join("\n")}`);
1235
+ }
1236
+ if (context.existingReviews.length > 0) {
1237
+ const reviewLines = context.existingReviews.map((r) => {
1238
+ const body = r.body ? ` ${r.body}` : "";
1239
+ return `@${r.author}: [${r.state}]${body}`;
1240
+ });
1241
+ sections.push(
1242
+ `## Existing Reviews (${context.existingReviews.length})
1243
+ ${reviewLines.join("\n")}`
1244
+ );
1245
+ }
1246
+ if (codebaseDir) {
1247
+ sections.push(`## Local Codebase
1248
+ The full repository is available at: ${codebaseDir}`);
1249
+ }
1250
+ return sanitizeTokens(sections.join("\n\n"));
1251
+ }
1252
+ function hasContent(context) {
1253
+ return context.metadata !== null || context.comments.length > 0 || context.reviewThreads.length > 0 || context.existingReviews.length > 0;
1254
+ }
1255
+
1256
+ // src/logger.ts
1257
+ import pc from "picocolors";
1258
+ var icons = {
1259
+ start: pc.green("\u25CF"),
1260
+ polling: pc.cyan("\u21BB"),
1261
+ success: pc.green("\u2713"),
1262
+ running: pc.blue("\u25B6"),
1263
+ stop: pc.red("\u25A0"),
1264
+ warn: pc.yellow("\u26A0"),
1265
+ error: pc.red("\u2717")
1266
+ };
1267
+ function timestamp() {
1268
+ const now = /* @__PURE__ */ new Date();
1269
+ const h = String(now.getHours()).padStart(2, "0");
1270
+ const m = String(now.getMinutes()).padStart(2, "0");
1271
+ const s = String(now.getSeconds()).padStart(2, "0");
1272
+ return `${h}:${m}:${s}`;
1273
+ }
968
1274
  function createLogger(label) {
969
- const prefix = label ? `[${label}] ` : "";
1275
+ const labelStr = label ? ` ${pc.dim(`[${label}]`)}` : "";
970
1276
  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)}`)
1277
+ log: (msg) => console.log(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${sanitizeTokens(msg)}`),
1278
+ logError: (msg) => console.error(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.red(sanitizeTokens(msg))}`),
1279
+ logWarn: (msg) => console.warn(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.yellow(sanitizeTokens(msg))}`)
974
1280
  };
975
1281
  }
1282
+ function createAgentSession() {
1283
+ return {
1284
+ startTime: Date.now(),
1285
+ tasksCompleted: 0,
1286
+ errorsEncountered: 0
1287
+ };
1288
+ }
1289
+ function formatUptime(ms) {
1290
+ const totalSeconds = Math.floor(ms / 1e3);
1291
+ const hours = Math.floor(totalSeconds / 3600);
1292
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
1293
+ const seconds = totalSeconds % 60;
1294
+ if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
1295
+ if (minutes > 0) return `${minutes}m${seconds}s`;
1296
+ return `${seconds}s`;
1297
+ }
1298
+ function formatExitSummary(stats) {
1299
+ const uptime = formatUptime(Date.now() - stats.startTime);
1300
+ const tasks = stats.tasksCompleted === 1 ? "1 task" : `${stats.tasksCompleted} tasks`;
1301
+ const errors = stats.errorsEncountered === 1 ? "1 error" : `${stats.errorsEncountered} errors`;
1302
+ return `${icons.stop} Shutting down \u2014 ${tasks} completed, ${errors}, uptime ${uptime}`;
1303
+ }
1304
+
1305
+ // src/commands/agent.ts
1306
+ var DEFAULT_POLL_INTERVAL_MS = 1e4;
1307
+ var MAX_CONSECUTIVE_AUTH_ERRORS = 3;
1308
+ var MAX_POLL_BACKOFF_MS = 3e5;
976
1309
  var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
977
1310
  function toApiDiffUrl(webUrl) {
978
1311
  const match = webUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\.diff)?$/);
@@ -1012,10 +1345,10 @@ async function fetchDiff(diffUrl, githubToken, signal) {
1012
1345
  );
1013
1346
  }
1014
1347
  var MAX_DIFF_FETCH_ATTEMPTS = 3;
1015
- async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, options) {
1348
+ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, options) {
1016
1349
  const { pollIntervalMs, maxConsecutiveErrors, routerRelay, reviewOnly, repoConfig, signal } = options;
1017
1350
  const { log, logError, logWarn } = logger;
1018
- log(`Agent ${agentId} polling every ${pollIntervalMs / 1e3}s...`);
1351
+ log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
1019
1352
  let consecutiveAuthErrors = 0;
1020
1353
  let consecutiveErrors = 0;
1021
1354
  const diffFailCounts = /* @__PURE__ */ new Map();
@@ -1023,6 +1356,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1023
1356
  try {
1024
1357
  const pollBody = { agent_id: agentId };
1025
1358
  if (reviewOnly) pollBody.review_only = true;
1359
+ if (repoConfig?.list?.length) {
1360
+ pollBody.repos = repoConfig.list;
1361
+ }
1026
1362
  const pollResponse = await client.post("/api/tasks/poll", pollBody);
1027
1363
  consecutiveAuthErrors = 0;
1028
1364
  consecutiveErrors = 0;
@@ -1039,10 +1375,12 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1039
1375
  consumptionDeps,
1040
1376
  agentInfo,
1041
1377
  logger,
1378
+ agentSession,
1042
1379
  routerRelay,
1043
1380
  signal
1044
1381
  );
1045
1382
  if (result.diffFetchFailed) {
1383
+ agentSession.errorsEncountered++;
1046
1384
  const count = (diffFailCounts.get(task.task_id) ?? 0) + 1;
1047
1385
  diffFailCounts.set(task.task_id, count);
1048
1386
  if (count >= MAX_DIFF_FETCH_ATTEMPTS) {
@@ -1052,20 +1390,21 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1052
1390
  }
1053
1391
  } catch (err) {
1054
1392
  if (signal?.aborted) break;
1393
+ agentSession.errorsEncountered++;
1055
1394
  if (err instanceof HttpError && (err.status === 401 || err.status === 403)) {
1056
1395
  consecutiveAuthErrors++;
1057
1396
  consecutiveErrors++;
1058
1397
  logError(
1059
- `Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
1398
+ `${icons.error} Auth error (${err.status}): ${err.message} [${consecutiveAuthErrors}/${MAX_CONSECUTIVE_AUTH_ERRORS}]`
1060
1399
  );
1061
1400
  if (consecutiveAuthErrors >= MAX_CONSECUTIVE_AUTH_ERRORS) {
1062
- logError("Authentication failed repeatedly. Exiting.");
1401
+ logError(`${icons.error} Authentication failed repeatedly. Exiting.`);
1063
1402
  break;
1064
1403
  }
1065
1404
  } else {
1066
1405
  consecutiveAuthErrors = 0;
1067
1406
  consecutiveErrors++;
1068
- logError(`Poll error: ${err.message}`);
1407
+ logError(`${icons.error} Poll error: ${err.message}`);
1069
1408
  }
1070
1409
  if (consecutiveErrors >= maxConsecutiveErrors) {
1071
1410
  logError(
@@ -1091,11 +1430,10 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
1091
1430
  await sleep2(pollIntervalMs, signal);
1092
1431
  }
1093
1432
  }
1094
- async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, routerRelay, signal) {
1433
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
1095
1434
  const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
1096
1435
  const { log, logError, logWarn } = logger;
1097
- log(`
1098
- Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1436
+ log(`${icons.success} Claimed task ${task_id} (${role}) \u2014 ${owner}/${repo}#${pr_number}`);
1099
1437
  log(` https://github.com/${owner}/${repo}/pull/${pr_number}`);
1100
1438
  let claimResponse;
1101
1439
  try {
@@ -1110,15 +1448,14 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1110
1448
  signal
1111
1449
  );
1112
1450
  } catch (err) {
1113
- const status = err instanceof HttpError ? ` (${err.status})` : "";
1114
- logError(` Failed to claim task ${task_id}${status}: ${err.message}`);
1115
- return {};
1116
- }
1117
- if (!claimResponse.claimed) {
1118
- log(` Claim rejected: ${claimResponse.reason}`);
1451
+ if (err instanceof HttpError) {
1452
+ const codeInfo = err.errorCode ? ` [${err.errorCode}]` : "";
1453
+ logError(` Claim rejected${codeInfo}: ${err.message}`);
1454
+ } else {
1455
+ logError(` Failed to claim task ${task_id}: ${err.message}`);
1456
+ }
1119
1457
  return {};
1120
1458
  }
1121
- log(` Claimed as ${role}`);
1122
1459
  let diffContent;
1123
1460
  try {
1124
1461
  diffContent = await fetchDiff(diff_url, reviewDeps.githubToken, signal);
@@ -1171,6 +1508,21 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1171
1508
  );
1172
1509
  }
1173
1510
  }
1511
+ let contextBlock;
1512
+ try {
1513
+ const prContext = await fetchPRContext(owner, repo, pr_number, {
1514
+ githubToken: reviewDeps.githubToken,
1515
+ signal
1516
+ });
1517
+ if (hasContent(prContext)) {
1518
+ contextBlock = formatPRContext(prContext, taskReviewDeps.codebaseDir);
1519
+ log(" PR context fetched");
1520
+ }
1521
+ } catch (err) {
1522
+ logWarn(
1523
+ ` Warning: failed to fetch PR context: ${err.message}. Continuing without.`
1524
+ );
1525
+ }
1174
1526
  try {
1175
1527
  if (role === "summary" && "reviews" in claimResponse && claimResponse.reviews) {
1176
1528
  await executeSummaryTask(
@@ -1188,7 +1540,8 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1188
1540
  consumptionDeps,
1189
1541
  logger,
1190
1542
  routerRelay,
1191
- signal
1543
+ signal,
1544
+ contextBlock
1192
1545
  );
1193
1546
  } else {
1194
1547
  await executeReviewTask(
@@ -1205,15 +1558,18 @@ Task ${task_id}: PR #${pr_number} on ${owner}/${repo} (role: ${role})`);
1205
1558
  consumptionDeps,
1206
1559
  logger,
1207
1560
  routerRelay,
1208
- signal
1561
+ signal,
1562
+ contextBlock
1209
1563
  );
1210
1564
  }
1565
+ agentSession.tasksCompleted++;
1211
1566
  } catch (err) {
1567
+ agentSession.errorsEncountered++;
1212
1568
  if (err instanceof DiffTooLargeError || err instanceof InputTooLargeError) {
1213
- logError(` ${err.message}`);
1569
+ logError(` ${icons.error} ${err.message}`);
1214
1570
  await safeReject(client, task_id, agentId, err.message, logger);
1215
1571
  } else {
1216
- logError(` Error on task ${task_id}: ${err.message}`);
1572
+ logError(` ${icons.error} Error on task ${task_id}: ${err.message}`);
1217
1573
  await safeError(client, task_id, agentId, err.message, logger);
1218
1574
  }
1219
1575
  } finally {
@@ -1253,18 +1609,19 @@ async function safeError(client, taskId, agentId, error, logger) {
1253
1609
  );
1254
1610
  }
1255
1611
  }
1256
- async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
1612
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
1257
1613
  let reviewText;
1258
1614
  let verdict;
1259
1615
  let tokensUsed;
1260
1616
  if (routerRelay) {
1261
- logger.log(` Executing review command: [router mode]`);
1617
+ logger.log(` ${icons.running} Executing review: [router mode]`);
1262
1618
  const fullPrompt = routerRelay.buildReviewPrompt({
1263
1619
  owner,
1264
1620
  repo,
1265
1621
  reviewMode: "full",
1266
1622
  prompt,
1267
- diffContent
1623
+ diffContent,
1624
+ contextBlock
1268
1625
  });
1269
1626
  const response = await routerRelay.sendPrompt(
1270
1627
  "review_request",
@@ -1277,7 +1634,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1277
1634
  verdict = parsed.verdict;
1278
1635
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1279
1636
  } else {
1280
- logger.log(` Executing review command: ${reviewDeps.commandTemplate}`);
1637
+ logger.log(` ${icons.running} Executing review: ${reviewDeps.commandTemplate}`);
1281
1638
  const result = await executeReview(
1282
1639
  {
1283
1640
  taskId,
@@ -1287,7 +1644,8 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1287
1644
  repo,
1288
1645
  prNumber,
1289
1646
  timeout: timeoutSeconds,
1290
- reviewMode: "full"
1647
+ reviewMode: "full",
1648
+ contextBlock
1291
1649
  },
1292
1650
  reviewDeps
1293
1651
  );
@@ -1308,22 +1666,23 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
1308
1666
  signal
1309
1667
  );
1310
1668
  recordSessionUsage(consumptionDeps.session, tokensUsed);
1311
- logger.log(` Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1669
+ logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
1312
1670
  logger.log(formatPostReviewStats(consumptionDeps.session));
1313
1671
  }
1314
- async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal) {
1672
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, routerRelay, signal, contextBlock) {
1315
1673
  if (reviews.length === 0) {
1316
1674
  let reviewText;
1317
1675
  let verdict;
1318
1676
  let tokensUsed2;
1319
1677
  if (routerRelay) {
1320
- logger.log(` Executing summary command: [router mode]`);
1678
+ logger.log(` ${icons.running} Executing summary: [router mode]`);
1321
1679
  const fullPrompt = routerRelay.buildReviewPrompt({
1322
1680
  owner,
1323
1681
  repo,
1324
1682
  reviewMode: "full",
1325
1683
  prompt,
1326
- diffContent
1684
+ diffContent,
1685
+ contextBlock
1327
1686
  });
1328
1687
  const response = await routerRelay.sendPrompt(
1329
1688
  "review_request",
@@ -1336,7 +1695,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1336
1695
  verdict = parsed.verdict;
1337
1696
  tokensUsed2 = estimateTokens(fullPrompt) + estimateTokens(response);
1338
1697
  } else {
1339
- logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
1698
+ logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
1340
1699
  const result = await executeReview(
1341
1700
  {
1342
1701
  taskId,
@@ -1346,7 +1705,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1346
1705
  repo,
1347
1706
  prNumber,
1348
1707
  timeout: timeoutSeconds,
1349
- reviewMode: "full"
1708
+ reviewMode: "full",
1709
+ contextBlock
1350
1710
  },
1351
1711
  reviewDeps
1352
1712
  );
@@ -1367,7 +1727,9 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1367
1727
  signal
1368
1728
  );
1369
1729
  recordSessionUsage(consumptionDeps.session, tokensUsed2);
1370
- logger.log(` Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`);
1730
+ logger.log(
1731
+ ` ${icons.success} Review submitted as summary (${tokensUsed2.toLocaleString()} tokens)`
1732
+ );
1371
1733
  logger.log(formatPostReviewStats(consumptionDeps.session));
1372
1734
  return;
1373
1735
  }
@@ -1381,13 +1743,14 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1381
1743
  let summaryText;
1382
1744
  let tokensUsed;
1383
1745
  if (routerRelay) {
1384
- logger.log(` Executing summary command: [router mode]`);
1746
+ logger.log(` ${icons.running} Executing summary: [router mode]`);
1385
1747
  const fullPrompt = routerRelay.buildSummaryPrompt({
1386
1748
  owner,
1387
1749
  repo,
1388
1750
  prompt,
1389
1751
  reviews: summaryReviews,
1390
- diffContent
1752
+ diffContent,
1753
+ contextBlock
1391
1754
  });
1392
1755
  const response = await routerRelay.sendPrompt(
1393
1756
  "summary_request",
@@ -1398,7 +1761,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1398
1761
  summaryText = response;
1399
1762
  tokensUsed = estimateTokens(fullPrompt) + estimateTokens(response);
1400
1763
  } else {
1401
- logger.log(` Executing summary command: ${reviewDeps.commandTemplate}`);
1764
+ logger.log(` ${icons.running} Executing summary: ${reviewDeps.commandTemplate}`);
1402
1765
  const result = await executeSummary(
1403
1766
  {
1404
1767
  taskId,
@@ -1408,7 +1771,8 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1408
1771
  repo,
1409
1772
  prNumber,
1410
1773
  timeout: timeoutSeconds,
1411
- diffContent
1774
+ diffContent,
1775
+ contextBlock
1412
1776
  },
1413
1777
  reviewDeps
1414
1778
  );
@@ -1427,7 +1791,7 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
1427
1791
  signal
1428
1792
  );
1429
1793
  recordSessionUsage(consumptionDeps.session, tokensUsed);
1430
- logger.log(` Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1794
+ logger.log(` ${icons.success} Summary submitted (${tokensUsed.toLocaleString()} tokens)`);
1431
1795
  logger.log(formatPostReviewStats(consumptionDeps.session));
1432
1796
  }
1433
1797
  function sleep2(ms, signal) {
@@ -1453,31 +1817,30 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
1453
1817
  const deps = consumptionDeps ?? { agentId, session };
1454
1818
  const logger = createLogger(options?.label);
1455
1819
  const { log, logError, logWarn } = logger;
1456
- log(`Agent ${agentId} starting...`);
1457
- log(`Platform: ${platformUrl}`);
1820
+ const agentSession = createAgentSession();
1821
+ log(`${icons.start} Agent started (polling ${platformUrl})`);
1458
1822
  log(`Model: ${agentInfo.model} | Tool: ${agentInfo.tool}`);
1459
1823
  if (!reviewDeps) {
1460
- logError("No review command configured. Set command in config.yml");
1824
+ logError(`${icons.error} No review command configured. Set command in config.yml`);
1461
1825
  return;
1462
1826
  }
1463
1827
  if (reviewDeps.commandTemplate && !options?.routerRelay) {
1464
1828
  log("Testing command...");
1465
1829
  const result = await testCommand(reviewDeps.commandTemplate);
1466
1830
  if (result.ok) {
1467
- log(`Testing command... ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
1831
+ log(`${icons.success} Command test ok (${(result.elapsedMs / 1e3).toFixed(1)}s)`);
1468
1832
  } else {
1469
- logWarn(`Warning: command test failed (${result.error}). Reviews may fail.`);
1833
+ logWarn(`${icons.warn} Command test failed (${result.error}). Reviews may fail.`);
1470
1834
  }
1471
1835
  }
1472
1836
  const abortController = new AbortController();
1473
1837
  process.on("SIGINT", () => {
1474
- log("\nShutting down...");
1475
1838
  abortController.abort();
1476
1839
  });
1477
1840
  process.on("SIGTERM", () => {
1478
1841
  abortController.abort();
1479
1842
  });
1480
- await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, {
1843
+ await pollLoop(client, agentId, reviewDeps, deps, agentInfo, logger, agentSession, {
1481
1844
  pollIntervalMs: options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
1482
1845
  maxConsecutiveErrors: options?.maxConsecutiveErrors ?? DEFAULT_MAX_CONSECUTIVE_ERRORS,
1483
1846
  routerRelay: options?.routerRelay,
@@ -1485,7 +1848,7 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
1485
1848
  repoConfig: options?.repoConfig,
1486
1849
  signal: abortController.signal
1487
1850
  });
1488
- log("Agent stopped.");
1851
+ log(formatExitSummary(agentSession));
1489
1852
  }
1490
1853
  async function startAgentRouter() {
1491
1854
  const config = loadConfig();
@@ -1661,7 +2024,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
1661
2024
  });
1662
2025
 
1663
2026
  // src/index.ts
1664
- var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.11.0");
2027
+ var program = new Command2().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.12.1");
1665
2028
  program.addCommand(agentCommand);
1666
2029
  program.action(() => {
1667
2030
  startAgentRouter();
package/package.json CHANGED
@@ -1,17 +1,40 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
+ "description": "Distributed AI code review agent — poll, review, and submit PR reviews using your own AI tools",
4
5
  "type": "module",
6
+ "license": "MIT",
7
+ "author": "OpenCara <https://github.com/OpenCara>",
8
+ "homepage": "https://github.com/OpenCara/OpenCara#readme",
5
9
  "repository": {
6
10
  "type": "git",
7
11
  "url": "https://github.com/OpenCara/OpenCara.git",
8
12
  "directory": "packages/cli"
9
13
  },
14
+ "bugs": {
15
+ "url": "https://github.com/OpenCara/OpenCara/issues"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "code-review",
20
+ "github",
21
+ "pull-request",
22
+ "cli",
23
+ "agent",
24
+ "review",
25
+ "openai",
26
+ "claude",
27
+ "gemini"
28
+ ],
29
+ "engines": {
30
+ "node": ">=20"
31
+ },
10
32
  "bin": {
11
33
  "opencara": "dist/index.js"
12
34
  },
13
35
  "files": [
14
- "dist"
36
+ "dist",
37
+ "README.md"
15
38
  ],
16
39
  "scripts": {
17
40
  "build": "tsup",
@@ -20,14 +43,15 @@
20
43
  },
21
44
  "dependencies": {
22
45
  "commander": "^13.0.0",
46
+ "picocolors": "^1.1.1",
23
47
  "yaml": "^2.7.0"
24
48
  },
25
49
  "devDependencies": {
26
- "@opencara/shared": "workspace:*",
27
- "@opencara/server": "workspace:*",
28
50
  "@cloudflare/workers-types": "^4.20250214.0",
29
- "hono": "^4.7.0",
51
+ "@opencara/server": "workspace:*",
52
+ "@opencara/shared": "workspace:*",
30
53
  "@types/node": "^22.0.0",
54
+ "hono": "^4.7.0",
31
55
  "tsup": "^8.5.1",
32
56
  "tsx": "^4.0.0"
33
57
  }