opencara 0.18.4 → 0.18.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +621 -198
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,8 +7,7 @@ import { Command as Command5 } from "commander";
7
7
  import { Command } from "commander";
8
8
  import { execFile } from "child_process";
9
9
  import crypto2 from "crypto";
10
- import * as fs6 from "fs";
11
- import * as path6 from "path";
10
+ import * as path8 from "path";
12
11
 
13
12
  // ../shared/dist/types.js
14
13
  function isDedupRole(role) {
@@ -17,15 +16,24 @@ function isDedupRole(role) {
17
16
  function isTriageRole(role) {
18
17
  return role === "pr_triage" || role === "issue_triage";
19
18
  }
20
- function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
19
+ function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner, userOrgs) {
21
20
  if (!repoConfig)
22
21
  return true;
23
22
  const fullRepo = `${targetOwner}/${targetRepo}`;
24
23
  switch (repoConfig.mode) {
25
- case "all":
24
+ case "public":
25
+ return true;
26
+ case "private": {
27
+ const normalizedTarget = targetOwner.toLowerCase();
28
+ const normalizedOwner = agentOwner?.toLowerCase();
29
+ const hasAccess = normalizedOwner === normalizedTarget || userOrgs != null && userOrgs.has(normalizedTarget);
30
+ if (!hasAccess)
31
+ return false;
32
+ if (repoConfig.list && repoConfig.list.length > 0) {
33
+ return repoConfig.list.includes(fullRepo);
34
+ }
26
35
  return true;
27
- case "own":
28
- return agentOwner === targetOwner;
36
+ }
29
37
  case "whitelist":
30
38
  return (repoConfig.list ?? []).includes(fullRepo);
31
39
  case "blacklist":
@@ -159,6 +167,20 @@ function parseStringArray(value) {
159
167
  return [];
160
168
  return value.filter((v) => typeof v === "string");
161
169
  }
170
+ var DEFAULT_MODEL_DIVERSITY_GRACE_MS = 3e4;
171
+ function parseDurationSeconds(value, defaultMs) {
172
+ if (typeof value === "number")
173
+ return value === 0 ? 0 : clamp(value, 0, 300) * 1e3;
174
+ if (typeof value !== "string")
175
+ return defaultMs;
176
+ if (value === "0" || value === "0s")
177
+ return 0;
178
+ const match = value.match(/^(\d+)s$/);
179
+ if (!match)
180
+ return defaultMs;
181
+ const seconds = parseInt(match[1], 10);
182
+ return clamp(seconds, 0, 300) * 1e3;
183
+ }
162
184
  var DEFAULT_TRIGGER = {
163
185
  on: ["opened"],
164
186
  comment: "/opencara review",
@@ -169,13 +191,14 @@ var DEFAULT_FEATURE_CONFIG = {
169
191
  agentCount: 1,
170
192
  timeout: "10m",
171
193
  preferredModels: [],
172
- preferredTools: []
194
+ preferredTools: [],
195
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
173
196
  };
174
197
  var DEFAULT_REVIEW_SECTION = {
175
198
  ...DEFAULT_FEATURE_CONFIG,
176
199
  trigger: DEFAULT_TRIGGER,
177
200
  reviewer: { whitelist: [], blacklist: [] },
178
- summarizer: { whitelist: [], blacklist: [], preferred: [] }
201
+ summarizer: { whitelist: [], blacklist: [], preferred: [], preferredModels: [] }
179
202
  };
180
203
  function toGithubEntity(name) {
181
204
  return { github: name };
@@ -184,27 +207,30 @@ function parseSummarizerSection(raw) {
184
207
  const defaults = {
185
208
  whitelist: [],
186
209
  blacklist: [],
187
- preferred: []
210
+ preferred: [],
211
+ preferredModels: []
188
212
  };
189
213
  if (typeof raw === "string") {
190
214
  return { ...defaults, preferred: [toGithubEntity(raw)] };
191
215
  }
192
216
  if (!isObject(raw))
193
217
  return defaults;
218
+ const preferredModels = parseStringArray(raw.preferred_models);
194
219
  if (raw.only !== void 0) {
195
220
  if (typeof raw.only === "string") {
196
- return { ...defaults, whitelist: [toGithubEntity(raw.only)] };
221
+ return { ...defaults, whitelist: [toGithubEntity(raw.only)], preferredModels };
197
222
  }
198
223
  if (Array.isArray(raw.only)) {
199
224
  const entries = raw.only.filter((v) => typeof v === "string").map((v) => toGithubEntity(v));
200
- return { ...defaults, whitelist: entries };
225
+ return { ...defaults, whitelist: entries, preferredModels };
201
226
  }
202
- return defaults;
227
+ return { ...defaults, preferredModels };
203
228
  }
204
229
  return {
205
230
  whitelist: parseEntityList(raw.whitelist),
206
231
  blacklist: parseEntityList(raw.blacklist),
207
- preferred: parseEntityList(raw.preferred)
232
+ preferred: parseEntityList(raw.preferred),
233
+ preferredModels
208
234
  };
209
235
  }
210
236
  function parseAgentSlots(value) {
@@ -235,6 +261,7 @@ function parseFeatureFields(raw, defaults) {
235
261
  timeout: parseTimeout(raw.timeout ?? defaults.timeout),
236
262
  preferredModels: parseStringArray(raw.preferred_models ?? defaults.preferredModels),
237
263
  preferredTools: parseStringArray(raw.preferred_tools ?? defaults.preferredTools),
264
+ modelDiversityGraceMs: parseDurationSeconds(raw.model_diversity_grace, defaults.modelDiversityGraceMs),
238
265
  ...agentSlots ? { agents: agentSlots } : {}
239
266
  };
240
267
  }
@@ -261,7 +288,8 @@ var DEFAULT_DEDUP_FEATURE = {
261
288
  agentCount: 1,
262
289
  timeout: "10m",
263
290
  preferredModels: [],
264
- preferredTools: []
291
+ preferredTools: [],
292
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
265
293
  };
266
294
  function parseDedupTarget(raw) {
267
295
  const base = parseFeatureFields(raw, DEFAULT_DEDUP_FEATURE);
@@ -291,7 +319,8 @@ var DEFAULT_TRIAGE_FEATURE = {
291
319
  agentCount: 1,
292
320
  timeout: "10m",
293
321
  preferredModels: [],
294
- preferredTools: []
322
+ preferredTools: [],
323
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
295
324
  };
296
325
  function parseTriageSection(raw) {
297
326
  const base = parseFeatureFields(raw, DEFAULT_TRIAGE_FEATURE);
@@ -360,6 +389,7 @@ function parseLegacyReviewConfig(raw) {
360
389
  timeout: parseTimeout(raw.timeout),
361
390
  preferredModels: parseStringArray(agentsRaw.preferred_models),
362
391
  preferredTools: parseStringArray(agentsRaw.preferred_tools),
392
+ modelDiversityGraceMs: parseDurationSeconds(raw.model_diversity_grace ?? agentsRaw.model_diversity_grace, DEFAULT_MODEL_DIVERSITY_GRACE_MS),
363
393
  trigger: {
364
394
  on: Array.isArray(triggerRaw.on) ? triggerRaw.on.filter((v) => typeof v === "string") : DEFAULT_TRIGGER.on,
365
395
  comment: typeof triggerRaw.comment === "string" ? triggerRaw.comment : DEFAULT_TRIGGER.comment,
@@ -387,8 +417,12 @@ function ensureConfigDir() {
387
417
  }
388
418
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
389
419
  var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
390
- var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
420
+ var VALID_REPO_MODES = ["public", "private", "whitelist", "blacklist"];
391
421
  var REPO_PATTERN = /^[^/]+\/[^/]+$/;
422
+ var REPO_MODE_ALIASES = {
423
+ all: "public",
424
+ own: "private"
425
+ };
392
426
  var RepoConfigError = class extends Error {
393
427
  constructor(message) {
394
428
  super(message);
@@ -412,10 +446,17 @@ function parseRepoConfig(obj, index, field = "repos") {
412
446
  throw new RepoConfigError(`agents[${index}].${field} must be an object`);
413
447
  }
414
448
  const reposObj = raw;
415
- const mode = reposObj.mode;
449
+ let mode = reposObj.mode;
416
450
  if (mode === void 0) {
417
451
  throw new RepoConfigError(`agents[${index}].${field}.mode is required`);
418
452
  }
453
+ if (typeof mode === "string" && Object.hasOwn(REPO_MODE_ALIASES, mode)) {
454
+ const resolved = REPO_MODE_ALIASES[mode];
455
+ console.warn(
456
+ `\u26A0 Config warning: agents[${index}].${field}.mode "${mode}" is deprecated, use "${resolved}" instead`
457
+ );
458
+ mode = resolved;
459
+ }
419
460
  if (typeof mode !== "string" || !VALID_REPO_MODES.includes(mode)) {
420
461
  throw new RepoConfigError(
421
462
  `agents[${index}].${field}.mode must be one of: ${VALID_REPO_MODES.join(", ")}`
@@ -509,6 +550,15 @@ function parseAgents(data) {
509
550
  );
510
551
  }
511
552
  if (typeof obj.codebase_dir === "string") agent.codebase_dir = obj.codebase_dir;
553
+ if (typeof obj.instances === "number") {
554
+ if (!Number.isInteger(obj.instances) || obj.instances < 1) {
555
+ console.warn(
556
+ `\u26A0 Config warning: agents[${i}].instances must be a positive integer, got ${obj.instances}. Value ignored.`
557
+ );
558
+ } else {
559
+ agent.instances = obj.instances;
560
+ }
561
+ }
512
562
  const repoConfig = parseRepoConfig(obj, i);
513
563
  if (repoConfig) agent.repos = repoConfig;
514
564
  const synthesizeRepoConfig = parseRepoConfig(obj, i, "synthesize_repos");
@@ -568,6 +618,7 @@ function loadConfig() {
568
618
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
569
619
  maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
570
620
  codebaseDir: null,
621
+ codebaseTtl: null,
571
622
  agentCommand: null,
572
623
  agents: null,
573
624
  usageLimits: {
@@ -611,6 +662,7 @@ function loadConfig() {
611
662
  maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
612
663
  maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
613
664
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
665
+ codebaseTtl: typeof data.codebase_ttl === "string" ? data.codebase_ttl : null,
614
666
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
615
667
  agents: parseAgents(data),
616
668
  usageLimits: {
@@ -629,10 +681,10 @@ function resolveCodebaseDir(agentDir, globalDir) {
629
681
  return path.resolve(raw);
630
682
  }
631
683
 
632
- // src/codebase.ts
633
- import { execFileSync } from "child_process";
634
- import * as fs2 from "fs";
635
- import * as path2 from "path";
684
+ // src/repo-cache.ts
685
+ import { execFileSync as execFileSync2 } from "child_process";
686
+ import * as fs3 from "fs";
687
+ import * as path3 from "path";
636
688
 
637
689
  // src/sanitize.ts
638
690
  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;
@@ -643,47 +695,10 @@ function sanitizeTokens(input) {
643
695
  }
644
696
 
645
697
  // src/codebase.ts
698
+ import { execFileSync } from "child_process";
699
+ import * as fs2 from "fs";
700
+ import * as path2 from "path";
646
701
  var VALID_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
647
- var GH_CREDENTIAL_HELPER = "!gh auth git-credential";
648
- function cloneOrUpdate(owner, repo, prNumber, baseDir, taskId) {
649
- validatePathSegment(owner, "owner");
650
- validatePathSegment(repo, "repo");
651
- if (taskId) {
652
- validatePathSegment(taskId, "taskId");
653
- }
654
- const repoDir = taskId ? path2.join(baseDir, owner, repo, taskId) : path2.join(baseDir, owner, repo);
655
- const ghAvailable = isGhAvailable();
656
- let cloned = false;
657
- if (!fs2.existsSync(path2.join(repoDir, ".git"))) {
658
- if (ghAvailable) {
659
- ghClone(owner, repo, repoDir);
660
- } else {
661
- fs2.mkdirSync(repoDir, { recursive: true });
662
- const cloneUrl = buildCloneUrl(owner, repo);
663
- git(["clone", "--depth", "1", cloneUrl, repoDir]);
664
- }
665
- cloned = true;
666
- }
667
- const credArgs = ghAvailable ? ["-c", `credential.helper=${GH_CREDENTIAL_HELPER}`] : [];
668
- git(
669
- [...credArgs, "fetch", "--force", "--depth", "1", "origin", `pull/${prNumber}/head`],
670
- repoDir
671
- );
672
- git(["checkout", "FETCH_HEAD"], repoDir);
673
- return { localPath: repoDir, cloned };
674
- }
675
- function cleanupTaskDir(dirPath) {
676
- if (!path2.isAbsolute(dirPath) || dirPath.split(path2.sep).filter(Boolean).length < 3) {
677
- return;
678
- }
679
- try {
680
- fs2.rmSync(dirPath, { recursive: true, force: true });
681
- } catch (err) {
682
- if (err?.code !== "ENOENT") {
683
- console.warn(`[cleanup] Failed to remove ${dirPath}: ${err.message}`);
684
- }
685
- }
686
- }
687
702
  function validatePathSegment(segment, name) {
688
703
  if (!VALID_NAME_PATTERN.test(segment) || segment === "." || segment === "..") {
689
704
  throw new Error(`Invalid ${name}: '${segment}' contains disallowed characters`);
@@ -704,24 +719,111 @@ function isGhAvailable() {
704
719
  return false;
705
720
  }
706
721
  }
707
- function ghClone(owner, repo, targetDir) {
722
+
723
+ // src/repo-cache.ts
724
+ var GH_CREDENTIAL_HELPER = "!gh auth git-credential";
725
+ var GIT_TIMEOUT_MS = 12e4;
726
+ var repoLocks = /* @__PURE__ */ new Map();
727
+ async function withRepoLock(repoKey, fn) {
728
+ const existing = repoLocks.get(repoKey);
729
+ let release;
730
+ const gate = new Promise((resolve2) => {
731
+ release = resolve2;
732
+ });
733
+ repoLocks.set(repoKey, gate);
708
734
  try {
709
- execFileSync("gh", ["repo", "clone", `${owner}/${repo}`, targetDir, "--", "--depth", "1"], {
710
- encoding: "utf-8",
711
- timeout: 12e4,
712
- stdio: ["ignore", "pipe", "pipe"]
713
- });
714
- } catch (err) {
715
- const message = err instanceof Error ? err.message : String(err);
716
- throw new Error(sanitizeTokens(message));
735
+ if (existing) await existing;
736
+ return await fn();
737
+ } finally {
738
+ release();
739
+ if (repoLocks.get(repoKey) === gate) {
740
+ repoLocks.delete(repoKey);
741
+ }
742
+ }
743
+ }
744
+ function ensureBareClone(owner, repo, baseDir, ghAvailable) {
745
+ validatePathSegment(owner, "owner");
746
+ validatePathSegment(repo, "repo");
747
+ const bareRepoPath = path3.join(baseDir, owner, `${repo}.git`);
748
+ if (fs3.existsSync(path3.join(bareRepoPath, "HEAD"))) {
749
+ return { bareRepoPath, cloned: false };
750
+ }
751
+ fs3.mkdirSync(path3.join(baseDir, owner), { recursive: true });
752
+ if (ghAvailable) {
753
+ gitExec("gh", [
754
+ "repo",
755
+ "clone",
756
+ `${owner}/${repo}`,
757
+ bareRepoPath,
758
+ "--",
759
+ "--bare",
760
+ "--filter=blob:none"
761
+ ]);
762
+ } else {
763
+ const cloneUrl = buildCloneUrl(owner, repo);
764
+ gitExec("git", ["clone", "--bare", "--filter=blob:none", cloneUrl, bareRepoPath]);
717
765
  }
766
+ return { bareRepoPath, cloned: true };
767
+ }
768
+ function fetchPRRef(bareRepoPath, prNumber, ghAvailable) {
769
+ const credArgs = ghAvailable ? ["-c", `credential.helper=${GH_CREDENTIAL_HELPER}`] : [];
770
+ gitExec(
771
+ "git",
772
+ [...credArgs, "fetch", "--force", "origin", `pull/${prNumber}/head`],
773
+ bareRepoPath
774
+ );
775
+ }
776
+ function addWorktree(bareRepoPath, taskId) {
777
+ validatePathSegment(taskId, "taskId");
778
+ const repoName = path3.basename(bareRepoPath, ".git");
779
+ const worktreeBase = path3.join(path3.dirname(bareRepoPath), `${repoName}-worktrees`);
780
+ const worktreePath = path3.join(worktreeBase, taskId);
781
+ fs3.mkdirSync(worktreeBase, { recursive: true });
782
+ gitExec("git", ["worktree", "add", "--detach", worktreePath, "FETCH_HEAD"], bareRepoPath);
783
+ return worktreePath;
784
+ }
785
+ function removeWorktree(bareRepoPath, worktreePath) {
786
+ try {
787
+ gitExec("git", ["worktree", "remove", "--force", worktreePath], bareRepoPath);
788
+ } catch {
789
+ try {
790
+ fs3.rmSync(worktreePath, { recursive: true, force: true });
791
+ gitExec("git", ["worktree", "prune"], bareRepoPath);
792
+ } catch {
793
+ console.warn(`[repo-cache] Failed to clean up worktree: ${worktreePath}`);
794
+ }
795
+ }
796
+ }
797
+ function repoKeyFromBarePath(bareRepoPath) {
798
+ const repoName = path3.basename(bareRepoPath, ".git");
799
+ const owner = path3.basename(path3.dirname(bareRepoPath));
800
+ return `${owner}/${repoName}`;
801
+ }
802
+ async function checkoutWorktree(owner, repo, prNumber, baseDir, taskId) {
803
+ validatePathSegment(owner, "owner");
804
+ validatePathSegment(repo, "repo");
805
+ validatePathSegment(taskId, "taskId");
806
+ const repoKey = `${owner}/${repo}`;
807
+ const ghAvailable = isGhAvailable();
808
+ return withRepoLock(repoKey, () => {
809
+ const { bareRepoPath, cloned } = ensureBareClone(owner, repo, baseDir, ghAvailable);
810
+ fetchPRRef(bareRepoPath, prNumber, ghAvailable);
811
+ const worktreePath = addWorktree(bareRepoPath, taskId);
812
+ return { worktreePath, bareRepoPath, cloned };
813
+ });
718
814
  }
719
- function git(args, cwd) {
815
+ async function cleanupWorktree(bareRepoPath, worktreePath) {
816
+ const repoKey = repoKeyFromBarePath(bareRepoPath);
817
+ await withRepoLock(repoKey, () => {
818
+ removeWorktree(bareRepoPath, worktreePath);
819
+ });
820
+ }
821
+ function gitExec(command, args, cwd) {
720
822
  try {
721
- return execFileSync("git", args, {
823
+ return execFileSync2(command, args, {
722
824
  cwd,
723
825
  encoding: "utf-8",
724
- timeout: 12e4,
826
+ timeout: GIT_TIMEOUT_MS,
725
827
  stdio: ["ignore", "pipe", "pipe"]
726
828
  });
727
829
  } catch (err) {
@@ -730,20 +832,170 @@ function git(args, cwd) {
730
832
  }
731
833
  }
732
834
 
835
+ // src/codebase-cleanup.ts
836
+ import * as fs4 from "fs";
837
+ import * as path4 from "path";
838
+ var DEFAULT_CODEBASE_TTL_MS = 30 * 60 * 1e3;
839
+ function parseTtl(value) {
840
+ const trimmed = value.trim();
841
+ if (trimmed === "0") return 0;
842
+ const match = trimmed.match(/^(\d+)\s*(ms|s|m|h|d)$/);
843
+ if (match) {
844
+ const num = parseInt(match[1], 10);
845
+ switch (match[2]) {
846
+ case "ms":
847
+ return num;
848
+ case "s":
849
+ return num * 1e3;
850
+ case "m":
851
+ return num * 60 * 1e3;
852
+ case "h":
853
+ return num * 60 * 60 * 1e3;
854
+ case "d":
855
+ return num * 24 * 60 * 60 * 1e3;
856
+ default:
857
+ throw new Error(`Unreachable: unhandled unit "${match[2]}"`);
858
+ }
859
+ }
860
+ if (/^\d+$/.test(trimmed)) {
861
+ return parseInt(trimmed, 10) * 1e3;
862
+ }
863
+ throw new Error(`Invalid codebase_ttl: "${value}". Use "0", "30m", "2h", "24h", "1d", etc.`);
864
+ }
865
+ var CodebaseCleanupTracker = class {
866
+ pending = [];
867
+ ttlMs;
868
+ constructor(ttlMs) {
869
+ this.ttlMs = ttlMs;
870
+ }
871
+ /**
872
+ * Record a completed task's worktree for deferred cleanup.
873
+ */
874
+ track(bareRepoPath, worktreePath) {
875
+ this.pending.push({
876
+ bareRepoPath,
877
+ worktreePath,
878
+ completedAt: Date.now()
879
+ });
880
+ }
881
+ /**
882
+ * Check for and remove any worktrees that have exceeded the TTL.
883
+ * Returns the number of directories cleaned up.
884
+ *
885
+ * The removeFn callback performs the actual git worktree removal.
886
+ */
887
+ async sweep(removeFn) {
888
+ const now = Date.now();
889
+ const expired = [];
890
+ const remaining = [];
891
+ for (const entry of this.pending) {
892
+ if (now - entry.completedAt >= this.ttlMs) {
893
+ expired.push(entry);
894
+ } else {
895
+ remaining.push(entry);
896
+ }
897
+ }
898
+ this.pending = remaining;
899
+ let cleaned = 0;
900
+ for (const entry of expired) {
901
+ try {
902
+ await removeFn(entry.bareRepoPath, entry.worktreePath);
903
+ cleaned++;
904
+ } catch {
905
+ this.pending.push(entry);
906
+ }
907
+ }
908
+ return cleaned;
909
+ }
910
+ /** Number of entries pending cleanup. */
911
+ get size() {
912
+ return this.pending.length;
913
+ }
914
+ };
915
+ function scanAndCleanStaleWorktrees(baseDir, ttlMs) {
916
+ if (!fs4.existsSync(baseDir)) return 0;
917
+ const now = Date.now();
918
+ let cleaned = 0;
919
+ let ownerDirs;
920
+ try {
921
+ ownerDirs = fs4.readdirSync(baseDir);
922
+ } catch {
923
+ return 0;
924
+ }
925
+ for (const ownerName of ownerDirs) {
926
+ const ownerPath = path4.join(baseDir, ownerName);
927
+ let stat;
928
+ try {
929
+ stat = fs4.statSync(ownerPath);
930
+ } catch {
931
+ continue;
932
+ }
933
+ if (!stat.isDirectory()) continue;
934
+ let entries;
935
+ try {
936
+ entries = fs4.readdirSync(ownerPath);
937
+ } catch {
938
+ continue;
939
+ }
940
+ for (const entry of entries) {
941
+ if (!entry.endsWith("-worktrees")) continue;
942
+ const worktreeBasePath = path4.join(ownerPath, entry);
943
+ let worktreeStat;
944
+ try {
945
+ worktreeStat = fs4.statSync(worktreeBasePath);
946
+ } catch {
947
+ continue;
948
+ }
949
+ if (!worktreeStat.isDirectory()) continue;
950
+ let taskDirs;
951
+ try {
952
+ taskDirs = fs4.readdirSync(worktreeBasePath);
953
+ } catch {
954
+ continue;
955
+ }
956
+ for (const taskId of taskDirs) {
957
+ const taskPath = path4.join(worktreeBasePath, taskId);
958
+ let taskStat;
959
+ try {
960
+ taskStat = fs4.statSync(taskPath);
961
+ } catch {
962
+ continue;
963
+ }
964
+ if (!taskStat.isDirectory()) continue;
965
+ const age = now - taskStat.mtimeMs;
966
+ if (age >= ttlMs) {
967
+ try {
968
+ fs4.rmSync(taskPath, { recursive: true, force: true });
969
+ const repoName = entry.replace(/-worktrees$/, "");
970
+ const metadataPath = path4.join(ownerPath, `${repoName}.git`, "worktrees", taskId);
971
+ try {
972
+ fs4.rmSync(metadataPath, { recursive: true, force: true });
973
+ } catch {
974
+ }
975
+ cleaned++;
976
+ } catch {
977
+ }
978
+ }
979
+ }
980
+ }
981
+ }
982
+ return cleaned;
983
+ }
984
+
733
985
  // src/auth.ts
734
- import * as fs3 from "fs";
735
- import * as path3 from "path";
986
+ import * as fs5 from "fs";
987
+ import * as path5 from "path";
736
988
  import * as os2 from "os";
737
989
  import * as crypto from "crypto";
738
- var AUTH_DIR = path3.join(os2.homedir(), ".opencara");
990
+ var AUTH_DIR = path5.join(os2.homedir(), ".opencara");
739
991
  function getAuthFilePath() {
740
992
  const envPath = process.env.OPENCARA_AUTH_FILE?.trim();
741
- return envPath || path3.join(AUTH_DIR, "auth.json");
993
+ return envPath || path5.join(AUTH_DIR, "auth.json");
742
994
  }
743
995
  function loadAuth() {
744
996
  const filePath = getAuthFilePath();
745
997
  try {
746
- const raw = fs3.readFileSync(filePath, "utf-8");
998
+ const raw = fs5.readFileSync(filePath, "utf-8");
747
999
  const data = JSON.parse(raw);
748
1000
  if (typeof data.access_token === "string" && typeof data.expires_at === "number" && typeof data.github_username === "string" && typeof data.github_user_id === "number" && // refresh_token is optional — tolerate non-refreshable tokens, but validate type when present
749
1001
  (data.refresh_token === void 0 || typeof data.refresh_token === "string")) {
@@ -756,15 +1008,15 @@ function loadAuth() {
756
1008
  }
757
1009
  function saveAuth(auth) {
758
1010
  const filePath = getAuthFilePath();
759
- const dir = path3.dirname(filePath);
760
- fs3.mkdirSync(dir, { recursive: true });
761
- const tmpPath = path3.join(dir, `.auth-${crypto.randomBytes(8).toString("hex")}.tmp`);
1011
+ const dir = path5.dirname(filePath);
1012
+ fs5.mkdirSync(dir, { recursive: true });
1013
+ const tmpPath = path5.join(dir, `.auth-${crypto.randomBytes(8).toString("hex")}.tmp`);
762
1014
  try {
763
- fs3.writeFileSync(tmpPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 384 });
764
- fs3.renameSync(tmpPath, filePath);
1015
+ fs5.writeFileSync(tmpPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 384 });
1016
+ fs5.renameSync(tmpPath, filePath);
765
1017
  } catch (err) {
766
1018
  try {
767
- fs3.unlinkSync(tmpPath);
1019
+ fs5.unlinkSync(tmpPath);
768
1020
  } catch {
769
1021
  }
770
1022
  throw err;
@@ -773,7 +1025,7 @@ function saveAuth(auth) {
773
1025
  function deleteAuth() {
774
1026
  const filePath = getAuthFilePath();
775
1027
  try {
776
- fs3.unlinkSync(filePath);
1028
+ fs5.unlinkSync(filePath);
777
1029
  } catch (err) {
778
1030
  if (err.code !== "ENOENT") {
779
1031
  throw err;
@@ -946,6 +1198,30 @@ async function resolveUser(token, fetchFn = fetch) {
946
1198
  }
947
1199
  return { login: data.login, id: data.id };
948
1200
  }
1201
+ async function fetchUserOrgs(token, fetchFn = fetch) {
1202
+ try {
1203
+ const res = await fetchFn("https://api.github.com/user/orgs?per_page=100", {
1204
+ headers: {
1205
+ Authorization: `Bearer ${token}`,
1206
+ Accept: "application/vnd.github+json",
1207
+ "X-GitHub-Api-Version": "2022-11-28"
1208
+ }
1209
+ });
1210
+ if (!res.ok) {
1211
+ return /* @__PURE__ */ new Set();
1212
+ }
1213
+ const data = await res.json();
1214
+ const orgs = /* @__PURE__ */ new Set();
1215
+ for (const org of data) {
1216
+ if (typeof org.login === "string") {
1217
+ orgs.add(org.login.toLowerCase());
1218
+ }
1219
+ }
1220
+ return orgs;
1221
+ } catch {
1222
+ return /* @__PURE__ */ new Set();
1223
+ }
1224
+ }
949
1225
 
950
1226
  // src/http.ts
951
1227
  var HttpError = class extends Error {
@@ -1043,27 +1319,27 @@ var ApiClient = class {
1043
1319
  clearTimeout(timer);
1044
1320
  }
1045
1321
  }
1046
- async get(path7) {
1047
- this.log(`GET ${path7}`);
1048
- const res = await this.timedFetch(`${this.baseUrl}${path7}`, {
1322
+ async get(path9) {
1323
+ this.log(`GET ${path9}`);
1324
+ const res = await this.timedFetch(`${this.baseUrl}${path9}`, {
1049
1325
  method: "GET",
1050
1326
  headers: this.headers()
1051
1327
  });
1052
- return this.handleResponse(res, path7, "GET");
1328
+ return this.handleResponse(res, path9, "GET");
1053
1329
  }
1054
- async post(path7, body) {
1055
- this.log(`POST ${path7}`);
1056
- const res = await this.timedFetch(`${this.baseUrl}${path7}`, {
1330
+ async post(path9, body) {
1331
+ this.log(`POST ${path9}`);
1332
+ const res = await this.timedFetch(`${this.baseUrl}${path9}`, {
1057
1333
  method: "POST",
1058
1334
  headers: this.headers(),
1059
1335
  body: body !== void 0 ? JSON.stringify(body) : void 0
1060
1336
  });
1061
- return this.handleResponse(res, path7, "POST", body);
1337
+ return this.handleResponse(res, path9, "POST", body);
1062
1338
  }
1063
- async handleResponse(res, path7, method, body) {
1339
+ async handleResponse(res, path9, method, body) {
1064
1340
  if (!res.ok) {
1065
1341
  const { message, errorCode, minimumVersion } = await this.parseErrorBody(res);
1066
- this.log(`${res.status} ${message} (${path7})`);
1342
+ this.log(`${res.status} ${message} (${path9})`);
1067
1343
  if (res.status === 426) {
1068
1344
  throw new UpgradeRequiredError(this.cliVersion ?? "unknown", minimumVersion);
1069
1345
  }
@@ -1072,12 +1348,12 @@ var ApiClient = class {
1072
1348
  try {
1073
1349
  this.authToken = await this.onTokenRefresh();
1074
1350
  this.log("Token refreshed, retrying request");
1075
- const retryRes = await this.timedFetch(`${this.baseUrl}${path7}`, {
1351
+ const retryRes = await this.timedFetch(`${this.baseUrl}${path9}`, {
1076
1352
  method,
1077
1353
  headers: this.headers(),
1078
1354
  body: body !== void 0 ? JSON.stringify(body) : void 0
1079
1355
  });
1080
- return this.handleRetryResponse(retryRes, path7);
1356
+ return this.handleRetryResponse(retryRes, path9);
1081
1357
  } catch (refreshErr) {
1082
1358
  this.log(`Token refresh failed: ${refreshErr.message}`);
1083
1359
  throw new HttpError(res.status, message, errorCode);
@@ -1085,20 +1361,20 @@ var ApiClient = class {
1085
1361
  }
1086
1362
  throw new HttpError(res.status, message, errorCode);
1087
1363
  }
1088
- this.log(`${res.status} OK (${path7})`);
1364
+ this.log(`${res.status} OK (${path9})`);
1089
1365
  return await res.json();
1090
1366
  }
1091
1367
  /** Handle response for a retry after token refresh — no second refresh attempt. */
1092
- async handleRetryResponse(res, path7) {
1368
+ async handleRetryResponse(res, path9) {
1093
1369
  if (!res.ok) {
1094
1370
  const { message, errorCode, minimumVersion } = await this.parseErrorBody(res);
1095
- this.log(`${res.status} ${message} (${path7}) [retry]`);
1371
+ this.log(`${res.status} ${message} (${path9}) [retry]`);
1096
1372
  if (res.status === 426) {
1097
1373
  throw new UpgradeRequiredError(this.cliVersion ?? "unknown", minimumVersion);
1098
1374
  }
1099
1375
  throw new HttpError(res.status, message, errorCode);
1100
1376
  }
1101
- this.log(`${res.status} OK (${path7}) [retry]`);
1377
+ this.log(`${res.status} OK (${path9}) [retry]`);
1102
1378
  return await res.json();
1103
1379
  }
1104
1380
  };
@@ -1153,9 +1429,9 @@ function sleep(ms, signal) {
1153
1429
  }
1154
1430
 
1155
1431
  // src/tool-executor.ts
1156
- import { spawn, execFileSync as execFileSync2 } from "child_process";
1157
- import * as fs4 from "fs";
1158
- import * as path4 from "path";
1432
+ import { spawn, execFileSync as execFileSync3 } from "child_process";
1433
+ import * as fs6 from "fs";
1434
+ import * as path6 from "path";
1159
1435
  var ToolTimeoutError = class extends Error {
1160
1436
  constructor(message) {
1161
1437
  super(message);
@@ -1167,9 +1443,9 @@ var MIN_PARTIAL_RESULT_LENGTH = 50;
1167
1443
  var MAX_STDERR_LENGTH = 1e3;
1168
1444
  function validateCommandBinary(commandTemplate) {
1169
1445
  const { command } = parseCommandTemplate(commandTemplate);
1170
- if (path4.isAbsolute(command)) {
1446
+ if (path6.isAbsolute(command)) {
1171
1447
  try {
1172
- fs4.accessSync(command, fs4.constants.X_OK);
1448
+ fs6.accessSync(command, fs6.constants.X_OK);
1173
1449
  return true;
1174
1450
  } catch {
1175
1451
  return false;
@@ -1178,9 +1454,9 @@ function validateCommandBinary(commandTemplate) {
1178
1454
  try {
1179
1455
  const isWindows = process.platform === "win32";
1180
1456
  if (isWindows) {
1181
- execFileSync2("where", [command], { stdio: "pipe" });
1457
+ execFileSync3("where", [command], { stdio: "pipe" });
1182
1458
  } else {
1183
- execFileSync2("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
1459
+ execFileSync3("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
1184
1460
  }
1185
1461
  return true;
1186
1462
  } catch {
@@ -1537,7 +1813,10 @@ ${userMessage}`;
1537
1813
  verdict,
1538
1814
  tokensUsed: result.tokensUsed + inputTokens,
1539
1815
  tokensEstimated: !result.tokensParsed,
1540
- tokenDetail
1816
+ tokenDetail,
1817
+ toolStdout: result.stdout,
1818
+ toolStderr: result.stderr,
1819
+ promptLength: fullPrompt.length
1541
1820
  };
1542
1821
  } finally {
1543
1822
  clearTimeout(abortTimer);
@@ -1716,7 +1995,10 @@ ${userMessage}`;
1716
1995
  tokensUsed: result.tokensUsed + inputTokens,
1717
1996
  tokensEstimated: !result.tokensParsed,
1718
1997
  tokenDetail,
1719
- flaggedReviews
1998
+ flaggedReviews,
1999
+ toolStdout: result.stdout,
2000
+ toolStderr: result.stderr,
2001
+ promptLength: fullPrompt.length
1720
2002
  };
1721
2003
  } finally {
1722
2004
  clearTimeout(abortTimer);
@@ -1914,9 +2196,9 @@ function formatPostReviewStats(session) {
1914
2196
  }
1915
2197
 
1916
2198
  // src/usage-tracker.ts
1917
- import * as fs5 from "fs";
1918
- import * as path5 from "path";
1919
- var USAGE_FILE = path5.join(CONFIG_DIR, "usage.json");
2199
+ import * as fs7 from "fs";
2200
+ import * as path7 from "path";
2201
+ var USAGE_FILE = path7.join(CONFIG_DIR, "usage.json");
1920
2202
  var MAX_HISTORY_DAYS = 30;
1921
2203
  var WARNING_THRESHOLD = 0.8;
1922
2204
  function todayKey() {
@@ -1939,8 +2221,8 @@ var UsageTracker = class {
1939
2221
  }
1940
2222
  load() {
1941
2223
  try {
1942
- if (fs5.existsSync(this.filePath)) {
1943
- const raw = fs5.readFileSync(this.filePath, "utf-8");
2224
+ if (fs7.existsSync(this.filePath)) {
2225
+ const raw = fs7.readFileSync(this.filePath, "utf-8");
1944
2226
  const parsed = JSON.parse(raw);
1945
2227
  if (parsed && Array.isArray(parsed.days)) {
1946
2228
  return parsed;
@@ -1952,7 +2234,7 @@ var UsageTracker = class {
1952
2234
  }
1953
2235
  save() {
1954
2236
  ensureConfigDir();
1955
- fs5.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), {
2237
+ fs7.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), {
1956
2238
  encoding: "utf-8",
1957
2239
  mode: 384
1958
2240
  });
@@ -2156,6 +2438,36 @@ function createLogger(label) {
2156
2438
  logWarn: (msg) => console.warn(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.yellow(sanitizeTokens(msg))}`)
2157
2439
  };
2158
2440
  }
2441
+ var VERBOSE_TRUNCATE_LIMIT = 2e3;
2442
+ var CHARS_PER_TOKEN_ESTIMATE = 4;
2443
+ function logVerboseToolOutput(logger, label, stdout, stderr, promptLength, limit = VERBOSE_TRUNCATE_LIMIT) {
2444
+ const estimatedTokens = Math.ceil(promptLength / CHARS_PER_TOKEN_ESTIMATE);
2445
+ logger.log(
2446
+ `${icons.info} [verbose] ${label} \u2014 prompt: ${promptLength} chars (~${estimatedTokens} tokens)`
2447
+ );
2448
+ if (stdout) {
2449
+ const truncated = stdout.length > limit ? stdout.slice(0, limit) + `
2450
+ ... (truncated at ${limit} chars)` : stdout;
2451
+ logger.log(
2452
+ `${icons.info} [verbose] ${label} stdout (${stdout.length} chars):
2453
+ ---
2454
+ ${truncated}
2455
+ ---`
2456
+ );
2457
+ } else {
2458
+ logger.log(`${icons.info} [verbose] ${label} stdout: (empty)`);
2459
+ }
2460
+ if (stderr) {
2461
+ const truncated = stderr.length > limit ? stderr.slice(0, limit) + `
2462
+ ... (truncated at ${limit} chars)` : stderr;
2463
+ logger.log(
2464
+ `${icons.info} [verbose] ${label} stderr (${stderr.length} chars):
2465
+ ---
2466
+ ${truncated}
2467
+ ---`
2468
+ );
2469
+ }
2470
+ }
2159
2471
  function createAgentSession() {
2160
2472
  return {
2161
2473
  startTime: Date.now(),
@@ -2926,7 +3238,11 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2926
3238
  repoConfig,
2927
3239
  roles,
2928
3240
  synthesizeRepos,
2929
- signal
3241
+ signal,
3242
+ cleanupTracker,
3243
+ verbose,
3244
+ agentOwner,
3245
+ userOrgs
2930
3246
  } = options;
2931
3247
  const { log, logError, logWarn } = logger;
2932
3248
  log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
@@ -2958,10 +3274,20 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2958
3274
  const pollResponse = await client.post("/api/tasks/poll", pollBody);
2959
3275
  consecutiveAuthErrors = 0;
2960
3276
  consecutiveErrors = 0;
2961
- const eligibleTasks = repoConfig ? pollResponse.tasks.filter((t) => isRepoAllowed(repoConfig, t.owner, t.repo)) : pollResponse.tasks;
3277
+ const eligibleTasks = repoConfig ? pollResponse.tasks.filter(
3278
+ (t) => isRepoAllowed(repoConfig, t.owner, t.repo, agentOwner, userOrgs)
3279
+ ) : pollResponse.tasks;
2962
3280
  const task = eligibleTasks.find(
2963
3281
  (t) => (diffFailCounts.get(t.task_id) ?? 0) < MAX_DIFF_FETCH_ATTEMPTS
2964
3282
  );
3283
+ if (cleanupTracker) {
3284
+ const swept = await cleanupTracker.sweep(cleanupWorktree);
3285
+ if (swept > 0) {
3286
+ log(
3287
+ `${icons.info} Cleaned up ${swept} stale codebase director${swept === 1 ? "y" : "ies"}`
3288
+ );
3289
+ }
3290
+ }
2965
3291
  if (task) {
2966
3292
  const result = await handleTask(
2967
3293
  client,
@@ -2973,7 +3299,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2973
3299
  logger,
2974
3300
  agentSession,
2975
3301
  routerRelay,
2976
- signal
3302
+ signal,
3303
+ cleanupTracker,
3304
+ verbose
2977
3305
  );
2978
3306
  if (result.diffFetchFailed) {
2979
3307
  agentSession.errorsEncountered++;
@@ -3031,7 +3359,7 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
3031
3359
  await sleep2(pollIntervalMs, signal);
3032
3360
  }
3033
3361
  }
3034
- async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
3362
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal, cleanupTracker, verbose) {
3035
3363
  const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
3036
3364
  const { log, logError, logWarn } = logger;
3037
3365
  const isIssueTask = pr_number === 0;
@@ -3068,6 +3396,7 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3068
3396
  let diffContent = "";
3069
3397
  let taskReviewDeps = reviewDeps;
3070
3398
  let taskCheckoutPath = null;
3399
+ let taskBareRepoPath = null;
3071
3400
  let contextBlock;
3072
3401
  if (isIssueTask) {
3073
3402
  log(" Issue-based task \u2014 skipping diff fetch");
@@ -3091,33 +3420,20 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3091
3420
  );
3092
3421
  return { diffFetchFailed: true };
3093
3422
  }
3094
- if (reviewDeps.codebaseDir) {
3423
+ {
3424
+ const codebaseDir = reviewDeps.codebaseDir || path8.join(CONFIG_DIR, "repos");
3095
3425
  try {
3096
- const result = cloneOrUpdate(owner, repo, pr_number, reviewDeps.codebaseDir, task_id);
3097
- log(` Codebase ${result.cloned ? "cloned" : "updated"}: ${result.localPath}`);
3098
- taskCheckoutPath = result.localPath;
3099
- taskReviewDeps = { ...reviewDeps, codebaseDir: result.localPath };
3426
+ const result = await checkoutWorktree(owner, repo, pr_number, codebaseDir, task_id);
3427
+ log(` Codebase ${result.cloned ? "cloned" : "cached"} \u2192 worktree: ${result.worktreePath}`);
3428
+ taskCheckoutPath = result.worktreePath;
3429
+ taskBareRepoPath = result.bareRepoPath;
3430
+ taskReviewDeps = { ...reviewDeps, codebaseDir: result.worktreePath };
3100
3431
  } catch (err) {
3101
3432
  logWarn(
3102
- ` Warning: codebase clone failed: ${err.message}. Continuing with diff-only review.`
3433
+ ` Warning: worktree checkout failed: ${err.message}. Continuing with diff-only review.`
3103
3434
  );
3104
3435
  taskReviewDeps = { ...reviewDeps, codebaseDir: null };
3105
3436
  }
3106
- } else {
3107
- try {
3108
- validatePathSegment(owner, "owner");
3109
- validatePathSegment(repo, "repo");
3110
- validatePathSegment(task_id, "task_id");
3111
- const repoScopedDir = path6.join(CONFIG_DIR, "repos", owner, repo, task_id);
3112
- fs6.mkdirSync(repoScopedDir, { recursive: true });
3113
- taskCheckoutPath = repoScopedDir;
3114
- taskReviewDeps = { ...reviewDeps, codebaseDir: repoScopedDir };
3115
- log(` Working directory: ${repoScopedDir}`);
3116
- } catch (err) {
3117
- logWarn(
3118
- ` Warning: failed to create working directory: ${err.message}. Continuing without scoped cwd.`
3119
- );
3120
- }
3121
3437
  }
3122
3438
  try {
3123
3439
  const prContext = await fetchPRContext(owner, repo, pr_number, {
@@ -3219,7 +3535,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3219
3535
  agentInfo,
3220
3536
  routerRelay,
3221
3537
  signal,
3222
- contextBlock
3538
+ contextBlock,
3539
+ verbose
3223
3540
  );
3224
3541
  } else {
3225
3542
  await executeReviewTask(
@@ -3238,7 +3555,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3238
3555
  agentInfo,
3239
3556
  routerRelay,
3240
3557
  signal,
3241
- contextBlock
3558
+ contextBlock,
3559
+ verbose
3242
3560
  );
3243
3561
  }
3244
3562
  agentSession.tasksCompleted++;
@@ -3252,8 +3570,12 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3252
3570
  await safeError(client, task_id, agentId, err.message, logger);
3253
3571
  }
3254
3572
  } finally {
3255
- if (taskCheckoutPath) {
3256
- cleanupTaskDir(taskCheckoutPath);
3573
+ if (taskCheckoutPath && taskBareRepoPath) {
3574
+ if (cleanupTracker) {
3575
+ cleanupTracker.track(taskBareRepoPath, taskCheckoutPath);
3576
+ } else {
3577
+ await cleanupWorktree(taskBareRepoPath, taskCheckoutPath);
3578
+ }
3257
3579
  }
3258
3580
  }
3259
3581
  return {};
@@ -3288,7 +3610,7 @@ async function safeError(client, taskId, agentId, error, logger) {
3288
3610
  );
3289
3611
  }
3290
3612
  }
3291
- async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock) {
3613
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock, verbose) {
3292
3614
  if (consumptionDeps.usageLimits?.maxTokensPerReview != null && consumptionDeps.usageTracker) {
3293
3615
  const estimatedInput = estimateTokens(diffContent + prompt + (contextBlock ?? ""));
3294
3616
  const perReviewCheck = consumptionDeps.usageTracker.checkPerReviewLimit(
@@ -3354,6 +3676,15 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
3354
3676
  totalTokens: result.tokensUsed,
3355
3677
  estimated: result.tokensEstimated
3356
3678
  };
3679
+ if (verbose) {
3680
+ logVerboseToolOutput(
3681
+ logger,
3682
+ "Review",
3683
+ result.toolStdout,
3684
+ result.toolStderr,
3685
+ result.promptLength
3686
+ );
3687
+ }
3357
3688
  }
3358
3689
  const reviewMeta = {
3359
3690
  model: agentInfo.model,
@@ -3383,7 +3714,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
3383
3714
  logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
3384
3715
  logger.log(formatPostReviewStats(consumptionDeps.session));
3385
3716
  }
3386
- async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock) {
3717
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock, verbose) {
3387
3718
  const meta = { model: agentInfo.model, tool: agentInfo.tool };
3388
3719
  if (reviews.length === 0) {
3389
3720
  let reviewText;
@@ -3441,6 +3772,15 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
3441
3772
  totalTokens: result.tokensUsed,
3442
3773
  estimated: result.tokensEstimated
3443
3774
  };
3775
+ if (verbose) {
3776
+ logVerboseToolOutput(
3777
+ logger,
3778
+ "Summary (single-agent)",
3779
+ result.toolStdout,
3780
+ result.toolStderr,
3781
+ result.promptLength
3782
+ );
3783
+ }
3444
3784
  }
3445
3785
  const headerSingle = buildMetadataHeader(verdict ?? "comment", meta);
3446
3786
  const sanitizedReview = sanitizeTokens(headerSingle + reviewText);
@@ -3534,6 +3874,15 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
3534
3874
  totalTokens: result.tokensUsed,
3535
3875
  estimated: result.tokensEstimated
3536
3876
  };
3877
+ if (verbose) {
3878
+ logVerboseToolOutput(
3879
+ logger,
3880
+ "Summary",
3881
+ result.toolStdout,
3882
+ result.toolStderr,
3883
+ result.promptLength
3884
+ );
3885
+ }
3537
3886
  }
3538
3887
  if (flaggedReviews.length > 0) {
3539
3888
  logger.logWarn(
@@ -3592,7 +3941,7 @@ function sleep2(ms, signal) {
3592
3941
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
3593
3942
  const client = new ApiClient(platformUrl, {
3594
3943
  authToken: options?.authToken,
3595
- cliVersion: "0.18.4",
3944
+ cliVersion: "0.18.6",
3596
3945
  versionOverride: options?.versionOverride,
3597
3946
  onTokenRefresh: options?.onTokenRefresh
3598
3947
  });
@@ -3617,6 +3966,9 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3617
3966
  if (options?.versionOverride) {
3618
3967
  log(`${icons.info} Version override active: ${options.versionOverride}`);
3619
3968
  }
3969
+ if (options?.verbose) {
3970
+ log(`${icons.info} Verbose mode enabled \u2014 tool stdout/stderr will be logged`);
3971
+ }
3620
3972
  if (!reviewDeps) {
3621
3973
  logError(`${icons.error} No review command configured. Set command in config.toml`);
3622
3974
  return;
@@ -3630,6 +3982,16 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3630
3982
  logWarn(`${icons.warn} Command test failed (${result.error}). Reviews may fail.`);
3631
3983
  }
3632
3984
  }
3985
+ const ttlMs = options?.codebaseTtl != null ? parseTtl(options.codebaseTtl) : 0;
3986
+ const codebaseDir = reviewDeps.codebaseDir || path8.join(CONFIG_DIR, "repos");
3987
+ const scanTtl = Math.max(ttlMs, DEFAULT_CODEBASE_TTL_MS);
3988
+ const staleCount = scanAndCleanStaleWorktrees(codebaseDir, scanTtl);
3989
+ if (staleCount > 0) {
3990
+ log(
3991
+ `${icons.info} Cleaned up ${staleCount} stale codebase director${staleCount === 1 ? "y" : "ies"} on startup`
3992
+ );
3993
+ }
3994
+ const cleanupTracker = ttlMs > 0 ? new CodebaseCleanupTracker(ttlMs) : void 0;
3633
3995
  const abortController = new AbortController();
3634
3996
  process.on("SIGINT", () => {
3635
3997
  abortController.abort();
@@ -3645,8 +4007,20 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3645
4007
  repoConfig: options?.repoConfig,
3646
4008
  roles: options?.roles,
3647
4009
  synthesizeRepos: options?.synthesizeRepos,
3648
- signal: abortController.signal
4010
+ signal: abortController.signal,
4011
+ cleanupTracker,
4012
+ verbose: options?.verbose,
4013
+ agentOwner: options?.agentOwner,
4014
+ userOrgs: options?.userOrgs
3649
4015
  });
4016
+ if (cleanupTracker && cleanupTracker.size > 0) {
4017
+ const finalSwept = await cleanupTracker.sweep(cleanupWorktree);
4018
+ if (finalSwept > 0) {
4019
+ log(
4020
+ `${icons.info} Cleaned up ${finalSwept} codebase director${finalSwept === 1 ? "y" : "ies"} on shutdown`
4021
+ );
4022
+ }
4023
+ }
3650
4024
  if (deps.usageTracker) {
3651
4025
  log(deps.usageTracker.formatSummary(deps.usageLimits ?? usageLimits));
3652
4026
  }
@@ -3679,9 +4053,12 @@ async function startAgentRouter() {
3679
4053
  throw err;
3680
4054
  }
3681
4055
  const storedAuth = loadAuth();
4056
+ const agentOwner = storedAuth?.github_username;
3682
4057
  if (storedAuth) {
3683
4058
  logger.log(`Authenticated as ${storedAuth.github_username}`);
3684
4059
  }
4060
+ const repoConfig = agentConfig?.repos;
4061
+ const userOrgs = repoConfig?.mode === "private" ? await fetchUserOrgs(oauthToken) : /* @__PURE__ */ new Set();
3685
4062
  const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
3686
4063
  const reviewDeps = {
3687
4064
  commandTemplate: commandTemplate ?? "",
@@ -3711,20 +4088,22 @@ async function startAgentRouter() {
3711
4088
  maxConsecutiveErrors: config.maxConsecutiveErrors,
3712
4089
  routerRelay: router,
3713
4090
  reviewOnly: agentConfig?.review_only,
3714
- repoConfig: agentConfig?.repos,
4091
+ repoConfig,
3715
4092
  roles,
3716
4093
  synthesizeRepos: agentConfig?.synthesize_repos,
3717
4094
  label,
3718
4095
  authToken: oauthToken,
3719
4096
  onTokenRefresh: () => getValidToken(config.platformUrl),
4097
+ agentOwner,
4098
+ userOrgs,
3720
4099
  usageLimits: config.usageLimits,
3721
- versionOverride
4100
+ versionOverride,
4101
+ codebaseTtl: config.codebaseTtl
3722
4102
  }
3723
4103
  );
3724
4104
  router.stop();
3725
4105
  }
3726
- function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versionOverride) {
3727
- const agentId = crypto2.randomUUID();
4106
+ function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versionOverride, verbose, instancesOverride, agentOwner, userOrgs) {
3728
4107
  let commandTemplate;
3729
4108
  let agentConfig;
3730
4109
  if (config.agents && config.agents.length > agentIndex) {
@@ -3744,58 +4123,78 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versi
3744
4123
  );
3745
4124
  return null;
3746
4125
  }
4126
+ const instanceCount = instancesOverride ?? agentConfig?.instances ?? 1;
3747
4127
  const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
3748
4128
  const reviewDeps = {
3749
4129
  commandTemplate,
3750
4130
  maxDiffSizeKb: config.maxDiffSizeKb,
3751
4131
  codebaseDir
3752
4132
  };
3753
- const isRouter = agentConfig?.router === true;
3754
- let routerRelay;
3755
- if (isRouter) {
3756
- routerRelay = new RouterRelay();
3757
- routerRelay.start();
3758
- }
3759
- const session = createSessionTracker();
3760
- const usageTracker = new UsageTracker();
3761
4133
  const model = agentConfig?.model ?? "unknown";
3762
4134
  const tool = agentConfig?.tool ?? "unknown";
3763
4135
  const thinking = agentConfig?.thinking;
3764
4136
  const roles = agentConfig ? computeRoles(agentConfig) : void 0;
3765
- const agentPromise = startAgent(
3766
- agentId,
3767
- config.platformUrl,
3768
- { model, tool, thinking },
3769
- reviewDeps,
3770
- { agentId, session, usageTracker, usageLimits: config.usageLimits },
3771
- {
3772
- pollIntervalMs,
3773
- maxConsecutiveErrors: config.maxConsecutiveErrors,
3774
- routerRelay,
3775
- reviewOnly: agentConfig?.review_only,
3776
- repoConfig: agentConfig?.repos,
3777
- roles,
3778
- synthesizeRepos: agentConfig?.synthesize_repos,
3779
- label,
3780
- authToken: oauthToken,
3781
- onTokenRefresh: () => getValidToken(config.platformUrl),
3782
- usageLimits: config.usageLimits,
3783
- versionOverride
4137
+ const session = createSessionTracker();
4138
+ const usageTracker = new UsageTracker();
4139
+ const promises = [];
4140
+ for (let inst = 0; inst < instanceCount; inst++) {
4141
+ const agentId = crypto2.randomUUID();
4142
+ const instanceLabel = instanceCount > 1 ? `${label}#${inst + 1}` : label;
4143
+ const isRouter = agentConfig?.router === true;
4144
+ let routerRelay;
4145
+ if (isRouter) {
4146
+ routerRelay = new RouterRelay();
4147
+ routerRelay.start();
3784
4148
  }
3785
- ).finally(() => {
3786
- routerRelay?.stop();
3787
- });
3788
- return agentPromise;
4149
+ const agentPromise = startAgent(
4150
+ agentId,
4151
+ config.platformUrl,
4152
+ { model, tool, thinking },
4153
+ reviewDeps,
4154
+ { agentId, session, usageTracker, usageLimits: config.usageLimits },
4155
+ {
4156
+ pollIntervalMs,
4157
+ maxConsecutiveErrors: config.maxConsecutiveErrors,
4158
+ routerRelay,
4159
+ reviewOnly: agentConfig?.review_only,
4160
+ repoConfig: agentConfig?.repos,
4161
+ roles,
4162
+ synthesizeRepos: agentConfig?.synthesize_repos,
4163
+ label: instanceLabel,
4164
+ authToken: oauthToken,
4165
+ onTokenRefresh: () => getValidToken(config.platformUrl),
4166
+ usageLimits: config.usageLimits,
4167
+ versionOverride,
4168
+ codebaseTtl: config.codebaseTtl,
4169
+ verbose,
4170
+ agentOwner,
4171
+ userOrgs
4172
+ }
4173
+ ).finally(() => {
4174
+ routerRelay?.stop();
4175
+ });
4176
+ promises.push(agentPromise);
4177
+ }
4178
+ return promises;
3789
4179
  }
3790
4180
  var agentCommand = new Command("agent").description("Manage review agents");
3791
4181
  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.toml (0-based)", "0").option("--all", "Start all configured agents concurrently").option(
3792
4182
  "--version-override <value>",
3793
4183
  "Cloudflare Workers version override (e.g. opencara-server=abc123)"
3794
- ).action(
4184
+ ).option("-v, --verbose", "Log tool stdout/stderr after each review/summary for debugging").option("--instances <count>", "Number of concurrent instances per agent (overrides config)").action(
3795
4185
  async (opts) => {
3796
4186
  const config = loadConfig();
3797
4187
  const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
3798
4188
  const versionOverride = opts.versionOverride || process.env.OPENCARA_VERSION_OVERRIDE || null;
4189
+ let instancesOverride;
4190
+ if (opts.instances !== void 0) {
4191
+ if (!/^[1-9]\d*$/.test(opts.instances)) {
4192
+ console.error("--instances must be a positive integer");
4193
+ process.exit(1);
4194
+ return;
4195
+ }
4196
+ instancesOverride = parseInt(opts.instances, 10);
4197
+ }
3799
4198
  let oauthToken;
3800
4199
  try {
3801
4200
  oauthToken = await getValidToken(config.platformUrl);
@@ -3808,22 +4207,35 @@ agentCommand.command("start").description("Start agents in polling mode").option
3808
4207
  throw err;
3809
4208
  }
3810
4209
  const storedAuth = loadAuth();
4210
+ const agentOwner = storedAuth?.github_username;
3811
4211
  if (storedAuth) {
3812
4212
  console.log(`Authenticated as ${storedAuth.github_username}`);
3813
4213
  }
4214
+ const needsOrgs = config.agents?.some((a) => a.repos?.mode === "private") ?? false;
4215
+ const userOrgs = needsOrgs ? await fetchUserOrgs(oauthToken) : /* @__PURE__ */ new Set();
3814
4216
  if (opts.all) {
3815
4217
  if (!config.agents || config.agents.length === 0) {
3816
4218
  console.error("No agents configured in ~/.opencara/config.toml");
3817
4219
  process.exit(1);
3818
4220
  return;
3819
4221
  }
3820
- console.log(`Starting ${config.agents.length} agent(s)...`);
4222
+ console.log(`Starting ${config.agents.length} agent config(s)...`);
3821
4223
  const promises = [];
3822
4224
  let startFailed = false;
3823
4225
  for (let i = 0; i < config.agents.length; i++) {
3824
- const p = startAgentByIndex(config, i, pollIntervalMs, oauthToken, versionOverride);
3825
- if (p) {
3826
- promises.push(p);
4226
+ const agentPromises = startAgentByIndex(
4227
+ config,
4228
+ i,
4229
+ pollIntervalMs,
4230
+ oauthToken,
4231
+ versionOverride,
4232
+ opts.verbose,
4233
+ instancesOverride,
4234
+ agentOwner,
4235
+ userOrgs
4236
+ );
4237
+ if (agentPromises) {
4238
+ promises.push(...agentPromises);
3827
4239
  } else {
3828
4240
  startFailed = true;
3829
4241
  }
@@ -3838,7 +4250,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
3838
4250
  "One or more agents could not start (see warnings above). Continuing with the rest."
3839
4251
  );
3840
4252
  }
3841
- console.log(`${promises.length} agent(s) running. Press Ctrl+C to stop all.
4253
+ console.log(`${promises.length} agent instance(s) running. Press Ctrl+C to stop all.
3842
4254
  `);
3843
4255
  const results = await Promise.allSettled(promises);
3844
4256
  const failures = results.filter((r) => r.status === "rejected");
@@ -3858,18 +4270,29 @@ agentCommand.command("start").description("Start agents in polling mode").option
3858
4270
  process.exit(1);
3859
4271
  return;
3860
4272
  }
3861
- const p = startAgentByIndex(
4273
+ const agentPromises = startAgentByIndex(
3862
4274
  config,
3863
4275
  agentIndex,
3864
4276
  pollIntervalMs,
3865
4277
  oauthToken,
3866
- versionOverride
4278
+ versionOverride,
4279
+ opts.verbose,
4280
+ instancesOverride,
4281
+ agentOwner,
4282
+ userOrgs
3867
4283
  );
3868
- if (!p) {
4284
+ if (!agentPromises) {
3869
4285
  process.exit(1);
3870
4286
  return;
3871
4287
  }
3872
- await p;
4288
+ const results = await Promise.allSettled(agentPromises);
4289
+ const failures = results.filter((r) => r.status === "rejected");
4290
+ if (failures.length > 0) {
4291
+ for (const f of failures) {
4292
+ console.error(`Agent instance failed: ${f.reason}`);
4293
+ }
4294
+ process.exit(1);
4295
+ }
3873
4296
  }
3874
4297
  }
3875
4298
  );
@@ -4005,8 +4428,8 @@ var PER_PAGE = 100;
4005
4428
  var OPEN_MARKER = "<!-- opencara-dedup-index:open -->";
4006
4429
  var RECENT_MARKER = "<!-- opencara-dedup-index:recent -->";
4007
4430
  var ARCHIVED_MARKER = "<!-- opencara-dedup-index:archived -->";
4008
- async function fetchRepoFile(owner, repo, path7, token, fetchFn = fetch) {
4009
- const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path7}`;
4431
+ async function fetchRepoFile(owner, repo, path9, token, fetchFn = fetch) {
4432
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path9}`;
4010
4433
  const res = await fetchFn(url, {
4011
4434
  headers: {
4012
4435
  Authorization: `Bearer ${token}`,
@@ -4014,7 +4437,7 @@ async function fetchRepoFile(owner, repo, path7, token, fetchFn = fetch) {
4014
4437
  }
4015
4438
  });
4016
4439
  if (res.status === 404) return null;
4017
- if (!res.ok) throw new Error(`GitHub API error: ${res.status} fetching ${path7}`);
4440
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status} fetching ${path9}`);
4018
4441
  return res.text();
4019
4442
  }
4020
4443
  async function fetchAllPRs(owner, repo, token, fetchFn = fetch, log) {
@@ -4572,7 +4995,7 @@ var statusCommand = new Command4("status").description("Show agent config, conne
4572
4995
  });
4573
4996
 
4574
4997
  // src/index.ts
4575
- var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.4");
4998
+ var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.6");
4576
4999
  program.addCommand(agentCommand);
4577
5000
  program.addCommand(authCommand());
4578
5001
  program.addCommand(dedupCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.18.4",
3
+ "version": "0.18.6",
4
4
  "description": "Distributed AI code review agent — poll, review, and submit PR reviews using your own AI tools",
5
5
  "type": "module",
6
6
  "license": "MIT",