opencara 0.18.3 → 0.18.5

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 +564 -194
  2. package/package.json +2 -5
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) {
@@ -22,9 +21,9 @@ function isRepoAllowed(repoConfig, targetOwner, targetRepo, agentOwner) {
22
21
  return true;
23
22
  const fullRepo = `${targetOwner}/${targetRepo}`;
24
23
  switch (repoConfig.mode) {
25
- case "all":
24
+ case "public":
26
25
  return true;
27
- case "own":
26
+ case "private":
28
27
  return agentOwner === targetOwner;
29
28
  case "whitelist":
30
29
  return (repoConfig.list ?? []).includes(fullRepo);
@@ -159,6 +158,20 @@ function parseStringArray(value) {
159
158
  return [];
160
159
  return value.filter((v) => typeof v === "string");
161
160
  }
161
+ var DEFAULT_MODEL_DIVERSITY_GRACE_MS = 3e4;
162
+ function parseDurationSeconds(value, defaultMs) {
163
+ if (typeof value === "number")
164
+ return value === 0 ? 0 : clamp(value, 0, 300) * 1e3;
165
+ if (typeof value !== "string")
166
+ return defaultMs;
167
+ if (value === "0" || value === "0s")
168
+ return 0;
169
+ const match = value.match(/^(\d+)s$/);
170
+ if (!match)
171
+ return defaultMs;
172
+ const seconds = parseInt(match[1], 10);
173
+ return clamp(seconds, 0, 300) * 1e3;
174
+ }
162
175
  var DEFAULT_TRIGGER = {
163
176
  on: ["opened"],
164
177
  comment: "/opencara review",
@@ -169,13 +182,14 @@ var DEFAULT_FEATURE_CONFIG = {
169
182
  agentCount: 1,
170
183
  timeout: "10m",
171
184
  preferredModels: [],
172
- preferredTools: []
185
+ preferredTools: [],
186
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
173
187
  };
174
188
  var DEFAULT_REVIEW_SECTION = {
175
189
  ...DEFAULT_FEATURE_CONFIG,
176
190
  trigger: DEFAULT_TRIGGER,
177
191
  reviewer: { whitelist: [], blacklist: [] },
178
- summarizer: { whitelist: [], blacklist: [], preferred: [] }
192
+ summarizer: { whitelist: [], blacklist: [], preferred: [], preferredModels: [] }
179
193
  };
180
194
  function toGithubEntity(name) {
181
195
  return { github: name };
@@ -184,27 +198,30 @@ function parseSummarizerSection(raw) {
184
198
  const defaults = {
185
199
  whitelist: [],
186
200
  blacklist: [],
187
- preferred: []
201
+ preferred: [],
202
+ preferredModels: []
188
203
  };
189
204
  if (typeof raw === "string") {
190
205
  return { ...defaults, preferred: [toGithubEntity(raw)] };
191
206
  }
192
207
  if (!isObject(raw))
193
208
  return defaults;
209
+ const preferredModels = parseStringArray(raw.preferred_models);
194
210
  if (raw.only !== void 0) {
195
211
  if (typeof raw.only === "string") {
196
- return { ...defaults, whitelist: [toGithubEntity(raw.only)] };
212
+ return { ...defaults, whitelist: [toGithubEntity(raw.only)], preferredModels };
197
213
  }
198
214
  if (Array.isArray(raw.only)) {
199
215
  const entries = raw.only.filter((v) => typeof v === "string").map((v) => toGithubEntity(v));
200
- return { ...defaults, whitelist: entries };
216
+ return { ...defaults, whitelist: entries, preferredModels };
201
217
  }
202
- return defaults;
218
+ return { ...defaults, preferredModels };
203
219
  }
204
220
  return {
205
221
  whitelist: parseEntityList(raw.whitelist),
206
222
  blacklist: parseEntityList(raw.blacklist),
207
- preferred: parseEntityList(raw.preferred)
223
+ preferred: parseEntityList(raw.preferred),
224
+ preferredModels
208
225
  };
209
226
  }
210
227
  function parseAgentSlots(value) {
@@ -235,6 +252,7 @@ function parseFeatureFields(raw, defaults) {
235
252
  timeout: parseTimeout(raw.timeout ?? defaults.timeout),
236
253
  preferredModels: parseStringArray(raw.preferred_models ?? defaults.preferredModels),
237
254
  preferredTools: parseStringArray(raw.preferred_tools ?? defaults.preferredTools),
255
+ modelDiversityGraceMs: parseDurationSeconds(raw.model_diversity_grace, defaults.modelDiversityGraceMs),
238
256
  ...agentSlots ? { agents: agentSlots } : {}
239
257
  };
240
258
  }
@@ -261,7 +279,8 @@ var DEFAULT_DEDUP_FEATURE = {
261
279
  agentCount: 1,
262
280
  timeout: "10m",
263
281
  preferredModels: [],
264
- preferredTools: []
282
+ preferredTools: [],
283
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
265
284
  };
266
285
  function parseDedupTarget(raw) {
267
286
  const base = parseFeatureFields(raw, DEFAULT_DEDUP_FEATURE);
@@ -291,7 +310,8 @@ var DEFAULT_TRIAGE_FEATURE = {
291
310
  agentCount: 1,
292
311
  timeout: "10m",
293
312
  preferredModels: [],
294
- preferredTools: []
313
+ preferredTools: [],
314
+ modelDiversityGraceMs: DEFAULT_MODEL_DIVERSITY_GRACE_MS
295
315
  };
296
316
  function parseTriageSection(raw) {
297
317
  const base = parseFeatureFields(raw, DEFAULT_TRIAGE_FEATURE);
@@ -360,6 +380,7 @@ function parseLegacyReviewConfig(raw) {
360
380
  timeout: parseTimeout(raw.timeout),
361
381
  preferredModels: parseStringArray(agentsRaw.preferred_models),
362
382
  preferredTools: parseStringArray(agentsRaw.preferred_tools),
383
+ modelDiversityGraceMs: parseDurationSeconds(raw.model_diversity_grace ?? agentsRaw.model_diversity_grace, DEFAULT_MODEL_DIVERSITY_GRACE_MS),
363
384
  trigger: {
364
385
  on: Array.isArray(triggerRaw.on) ? triggerRaw.on.filter((v) => typeof v === "string") : DEFAULT_TRIGGER.on,
365
386
  comment: typeof triggerRaw.comment === "string" ? triggerRaw.comment : DEFAULT_TRIGGER.comment,
@@ -387,8 +408,12 @@ function ensureConfigDir() {
387
408
  }
388
409
  var DEFAULT_MAX_DIFF_SIZE_KB = 100;
389
410
  var DEFAULT_MAX_CONSECUTIVE_ERRORS = 10;
390
- var VALID_REPO_MODES = ["all", "own", "whitelist", "blacklist"];
411
+ var VALID_REPO_MODES = ["public", "private", "whitelist", "blacklist"];
391
412
  var REPO_PATTERN = /^[^/]+\/[^/]+$/;
413
+ var REPO_MODE_ALIASES = {
414
+ all: "public",
415
+ own: "private"
416
+ };
392
417
  var RepoConfigError = class extends Error {
393
418
  constructor(message) {
394
419
  super(message);
@@ -412,10 +437,17 @@ function parseRepoConfig(obj, index, field = "repos") {
412
437
  throw new RepoConfigError(`agents[${index}].${field} must be an object`);
413
438
  }
414
439
  const reposObj = raw;
415
- const mode = reposObj.mode;
440
+ let mode = reposObj.mode;
416
441
  if (mode === void 0) {
417
442
  throw new RepoConfigError(`agents[${index}].${field}.mode is required`);
418
443
  }
444
+ if (typeof mode === "string" && Object.hasOwn(REPO_MODE_ALIASES, mode)) {
445
+ const resolved = REPO_MODE_ALIASES[mode];
446
+ console.warn(
447
+ `\u26A0 Config warning: agents[${index}].${field}.mode "${mode}" is deprecated, use "${resolved}" instead`
448
+ );
449
+ mode = resolved;
450
+ }
419
451
  if (typeof mode !== "string" || !VALID_REPO_MODES.includes(mode)) {
420
452
  throw new RepoConfigError(
421
453
  `agents[${index}].${field}.mode must be one of: ${VALID_REPO_MODES.join(", ")}`
@@ -509,6 +541,15 @@ function parseAgents(data) {
509
541
  );
510
542
  }
511
543
  if (typeof obj.codebase_dir === "string") agent.codebase_dir = obj.codebase_dir;
544
+ if (typeof obj.instances === "number") {
545
+ if (!Number.isInteger(obj.instances) || obj.instances < 1) {
546
+ console.warn(
547
+ `\u26A0 Config warning: agents[${i}].instances must be a positive integer, got ${obj.instances}. Value ignored.`
548
+ );
549
+ } else {
550
+ agent.instances = obj.instances;
551
+ }
552
+ }
512
553
  const repoConfig = parseRepoConfig(obj, i);
513
554
  if (repoConfig) agent.repos = repoConfig;
514
555
  const synthesizeRepoConfig = parseRepoConfig(obj, i, "synthesize_repos");
@@ -568,6 +609,7 @@ function loadConfig() {
568
609
  maxDiffSizeKb: DEFAULT_MAX_DIFF_SIZE_KB,
569
610
  maxConsecutiveErrors: DEFAULT_MAX_CONSECUTIVE_ERRORS,
570
611
  codebaseDir: null,
612
+ codebaseTtl: null,
571
613
  agentCommand: null,
572
614
  agents: null,
573
615
  usageLimits: {
@@ -611,6 +653,7 @@ function loadConfig() {
611
653
  maxDiffSizeKb: overrides.maxDiffSizeKb ?? (typeof data.max_diff_size_kb === "number" ? data.max_diff_size_kb : DEFAULT_MAX_DIFF_SIZE_KB),
612
654
  maxConsecutiveErrors: overrides.maxConsecutiveErrors ?? (typeof data.max_consecutive_errors === "number" ? data.max_consecutive_errors : DEFAULT_MAX_CONSECUTIVE_ERRORS),
613
655
  codebaseDir: typeof data.codebase_dir === "string" ? data.codebase_dir : null,
656
+ codebaseTtl: typeof data.codebase_ttl === "string" ? data.codebase_ttl : null,
614
657
  agentCommand: typeof data.agent_command === "string" ? data.agent_command : null,
615
658
  agents: parseAgents(data),
616
659
  usageLimits: {
@@ -629,10 +672,10 @@ function resolveCodebaseDir(agentDir, globalDir) {
629
672
  return path.resolve(raw);
630
673
  }
631
674
 
632
- // src/codebase.ts
633
- import { execFileSync } from "child_process";
634
- import * as fs2 from "fs";
635
- import * as path2 from "path";
675
+ // src/repo-cache.ts
676
+ import { execFileSync as execFileSync2 } from "child_process";
677
+ import * as fs3 from "fs";
678
+ import * as path3 from "path";
636
679
 
637
680
  // src/sanitize.ts
638
681
  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 +686,10 @@ function sanitizeTokens(input) {
643
686
  }
644
687
 
645
688
  // src/codebase.ts
689
+ import { execFileSync } from "child_process";
690
+ import * as fs2 from "fs";
691
+ import * as path2 from "path";
646
692
  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
693
  function validatePathSegment(segment, name) {
688
694
  if (!VALID_NAME_PATTERN.test(segment) || segment === "." || segment === "..") {
689
695
  throw new Error(`Invalid ${name}: '${segment}' contains disallowed characters`);
@@ -704,24 +710,111 @@ function isGhAvailable() {
704
710
  return false;
705
711
  }
706
712
  }
707
- function ghClone(owner, repo, targetDir) {
713
+
714
+ // src/repo-cache.ts
715
+ var GH_CREDENTIAL_HELPER = "!gh auth git-credential";
716
+ var GIT_TIMEOUT_MS = 12e4;
717
+ var repoLocks = /* @__PURE__ */ new Map();
718
+ async function withRepoLock(repoKey, fn) {
719
+ const existing = repoLocks.get(repoKey);
720
+ let release;
721
+ const gate = new Promise((resolve2) => {
722
+ release = resolve2;
723
+ });
724
+ repoLocks.set(repoKey, gate);
708
725
  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));
726
+ if (existing) await existing;
727
+ return await fn();
728
+ } finally {
729
+ release();
730
+ if (repoLocks.get(repoKey) === gate) {
731
+ repoLocks.delete(repoKey);
732
+ }
717
733
  }
718
734
  }
719
- function git(args, cwd) {
735
+ function ensureBareClone(owner, repo, baseDir, ghAvailable) {
736
+ validatePathSegment(owner, "owner");
737
+ validatePathSegment(repo, "repo");
738
+ const bareRepoPath = path3.join(baseDir, owner, `${repo}.git`);
739
+ if (fs3.existsSync(path3.join(bareRepoPath, "HEAD"))) {
740
+ return { bareRepoPath, cloned: false };
741
+ }
742
+ fs3.mkdirSync(path3.join(baseDir, owner), { recursive: true });
743
+ if (ghAvailable) {
744
+ gitExec("gh", [
745
+ "repo",
746
+ "clone",
747
+ `${owner}/${repo}`,
748
+ bareRepoPath,
749
+ "--",
750
+ "--bare",
751
+ "--filter=blob:none"
752
+ ]);
753
+ } else {
754
+ const cloneUrl = buildCloneUrl(owner, repo);
755
+ gitExec("git", ["clone", "--bare", "--filter=blob:none", cloneUrl, bareRepoPath]);
756
+ }
757
+ return { bareRepoPath, cloned: true };
758
+ }
759
+ function fetchPRRef(bareRepoPath, prNumber, ghAvailable) {
760
+ const credArgs = ghAvailable ? ["-c", `credential.helper=${GH_CREDENTIAL_HELPER}`] : [];
761
+ gitExec(
762
+ "git",
763
+ [...credArgs, "fetch", "--force", "origin", `pull/${prNumber}/head`],
764
+ bareRepoPath
765
+ );
766
+ }
767
+ function addWorktree(bareRepoPath, taskId) {
768
+ validatePathSegment(taskId, "taskId");
769
+ const repoName = path3.basename(bareRepoPath, ".git");
770
+ const worktreeBase = path3.join(path3.dirname(bareRepoPath), `${repoName}-worktrees`);
771
+ const worktreePath = path3.join(worktreeBase, taskId);
772
+ fs3.mkdirSync(worktreeBase, { recursive: true });
773
+ gitExec("git", ["worktree", "add", "--detach", worktreePath, "FETCH_HEAD"], bareRepoPath);
774
+ return worktreePath;
775
+ }
776
+ function removeWorktree(bareRepoPath, worktreePath) {
777
+ try {
778
+ gitExec("git", ["worktree", "remove", "--force", worktreePath], bareRepoPath);
779
+ } catch {
780
+ try {
781
+ fs3.rmSync(worktreePath, { recursive: true, force: true });
782
+ gitExec("git", ["worktree", "prune"], bareRepoPath);
783
+ } catch {
784
+ console.warn(`[repo-cache] Failed to clean up worktree: ${worktreePath}`);
785
+ }
786
+ }
787
+ }
788
+ function repoKeyFromBarePath(bareRepoPath) {
789
+ const repoName = path3.basename(bareRepoPath, ".git");
790
+ const owner = path3.basename(path3.dirname(bareRepoPath));
791
+ return `${owner}/${repoName}`;
792
+ }
793
+ async function checkoutWorktree(owner, repo, prNumber, baseDir, taskId) {
794
+ validatePathSegment(owner, "owner");
795
+ validatePathSegment(repo, "repo");
796
+ validatePathSegment(taskId, "taskId");
797
+ const repoKey = `${owner}/${repo}`;
798
+ const ghAvailable = isGhAvailable();
799
+ return withRepoLock(repoKey, () => {
800
+ const { bareRepoPath, cloned } = ensureBareClone(owner, repo, baseDir, ghAvailable);
801
+ fetchPRRef(bareRepoPath, prNumber, ghAvailable);
802
+ const worktreePath = addWorktree(bareRepoPath, taskId);
803
+ return { worktreePath, bareRepoPath, cloned };
804
+ });
805
+ }
806
+ async function cleanupWorktree(bareRepoPath, worktreePath) {
807
+ const repoKey = repoKeyFromBarePath(bareRepoPath);
808
+ await withRepoLock(repoKey, () => {
809
+ removeWorktree(bareRepoPath, worktreePath);
810
+ });
811
+ }
812
+ function gitExec(command, args, cwd) {
720
813
  try {
721
- return execFileSync("git", args, {
814
+ return execFileSync2(command, args, {
722
815
  cwd,
723
816
  encoding: "utf-8",
724
- timeout: 12e4,
817
+ timeout: GIT_TIMEOUT_MS,
725
818
  stdio: ["ignore", "pipe", "pipe"]
726
819
  });
727
820
  } catch (err) {
@@ -730,20 +823,170 @@ function git(args, cwd) {
730
823
  }
731
824
  }
732
825
 
826
+ // src/codebase-cleanup.ts
827
+ import * as fs4 from "fs";
828
+ import * as path4 from "path";
829
+ var DEFAULT_CODEBASE_TTL_MS = 30 * 60 * 1e3;
830
+ function parseTtl(value) {
831
+ const trimmed = value.trim();
832
+ if (trimmed === "0") return 0;
833
+ const match = trimmed.match(/^(\d+)\s*(ms|s|m|h|d)$/);
834
+ if (match) {
835
+ const num = parseInt(match[1], 10);
836
+ switch (match[2]) {
837
+ case "ms":
838
+ return num;
839
+ case "s":
840
+ return num * 1e3;
841
+ case "m":
842
+ return num * 60 * 1e3;
843
+ case "h":
844
+ return num * 60 * 60 * 1e3;
845
+ case "d":
846
+ return num * 24 * 60 * 60 * 1e3;
847
+ default:
848
+ throw new Error(`Unreachable: unhandled unit "${match[2]}"`);
849
+ }
850
+ }
851
+ if (/^\d+$/.test(trimmed)) {
852
+ return parseInt(trimmed, 10) * 1e3;
853
+ }
854
+ throw new Error(`Invalid codebase_ttl: "${value}". Use "0", "30m", "2h", "24h", "1d", etc.`);
855
+ }
856
+ var CodebaseCleanupTracker = class {
857
+ pending = [];
858
+ ttlMs;
859
+ constructor(ttlMs) {
860
+ this.ttlMs = ttlMs;
861
+ }
862
+ /**
863
+ * Record a completed task's worktree for deferred cleanup.
864
+ */
865
+ track(bareRepoPath, worktreePath) {
866
+ this.pending.push({
867
+ bareRepoPath,
868
+ worktreePath,
869
+ completedAt: Date.now()
870
+ });
871
+ }
872
+ /**
873
+ * Check for and remove any worktrees that have exceeded the TTL.
874
+ * Returns the number of directories cleaned up.
875
+ *
876
+ * The removeFn callback performs the actual git worktree removal.
877
+ */
878
+ async sweep(removeFn) {
879
+ const now = Date.now();
880
+ const expired = [];
881
+ const remaining = [];
882
+ for (const entry of this.pending) {
883
+ if (now - entry.completedAt >= this.ttlMs) {
884
+ expired.push(entry);
885
+ } else {
886
+ remaining.push(entry);
887
+ }
888
+ }
889
+ this.pending = remaining;
890
+ let cleaned = 0;
891
+ for (const entry of expired) {
892
+ try {
893
+ await removeFn(entry.bareRepoPath, entry.worktreePath);
894
+ cleaned++;
895
+ } catch {
896
+ this.pending.push(entry);
897
+ }
898
+ }
899
+ return cleaned;
900
+ }
901
+ /** Number of entries pending cleanup. */
902
+ get size() {
903
+ return this.pending.length;
904
+ }
905
+ };
906
+ function scanAndCleanStaleWorktrees(baseDir, ttlMs) {
907
+ if (!fs4.existsSync(baseDir)) return 0;
908
+ const now = Date.now();
909
+ let cleaned = 0;
910
+ let ownerDirs;
911
+ try {
912
+ ownerDirs = fs4.readdirSync(baseDir);
913
+ } catch {
914
+ return 0;
915
+ }
916
+ for (const ownerName of ownerDirs) {
917
+ const ownerPath = path4.join(baseDir, ownerName);
918
+ let stat;
919
+ try {
920
+ stat = fs4.statSync(ownerPath);
921
+ } catch {
922
+ continue;
923
+ }
924
+ if (!stat.isDirectory()) continue;
925
+ let entries;
926
+ try {
927
+ entries = fs4.readdirSync(ownerPath);
928
+ } catch {
929
+ continue;
930
+ }
931
+ for (const entry of entries) {
932
+ if (!entry.endsWith("-worktrees")) continue;
933
+ const worktreeBasePath = path4.join(ownerPath, entry);
934
+ let worktreeStat;
935
+ try {
936
+ worktreeStat = fs4.statSync(worktreeBasePath);
937
+ } catch {
938
+ continue;
939
+ }
940
+ if (!worktreeStat.isDirectory()) continue;
941
+ let taskDirs;
942
+ try {
943
+ taskDirs = fs4.readdirSync(worktreeBasePath);
944
+ } catch {
945
+ continue;
946
+ }
947
+ for (const taskId of taskDirs) {
948
+ const taskPath = path4.join(worktreeBasePath, taskId);
949
+ let taskStat;
950
+ try {
951
+ taskStat = fs4.statSync(taskPath);
952
+ } catch {
953
+ continue;
954
+ }
955
+ if (!taskStat.isDirectory()) continue;
956
+ const age = now - taskStat.mtimeMs;
957
+ if (age >= ttlMs) {
958
+ try {
959
+ fs4.rmSync(taskPath, { recursive: true, force: true });
960
+ const repoName = entry.replace(/-worktrees$/, "");
961
+ const metadataPath = path4.join(ownerPath, `${repoName}.git`, "worktrees", taskId);
962
+ try {
963
+ fs4.rmSync(metadataPath, { recursive: true, force: true });
964
+ } catch {
965
+ }
966
+ cleaned++;
967
+ } catch {
968
+ }
969
+ }
970
+ }
971
+ }
972
+ }
973
+ return cleaned;
974
+ }
975
+
733
976
  // src/auth.ts
734
- import * as fs3 from "fs";
735
- import * as path3 from "path";
977
+ import * as fs5 from "fs";
978
+ import * as path5 from "path";
736
979
  import * as os2 from "os";
737
980
  import * as crypto from "crypto";
738
- var AUTH_DIR = path3.join(os2.homedir(), ".opencara");
981
+ var AUTH_DIR = path5.join(os2.homedir(), ".opencara");
739
982
  function getAuthFilePath() {
740
983
  const envPath = process.env.OPENCARA_AUTH_FILE?.trim();
741
- return envPath || path3.join(AUTH_DIR, "auth.json");
984
+ return envPath || path5.join(AUTH_DIR, "auth.json");
742
985
  }
743
986
  function loadAuth() {
744
987
  const filePath = getAuthFilePath();
745
988
  try {
746
- const raw = fs3.readFileSync(filePath, "utf-8");
989
+ const raw = fs5.readFileSync(filePath, "utf-8");
747
990
  const data = JSON.parse(raw);
748
991
  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
992
  (data.refresh_token === void 0 || typeof data.refresh_token === "string")) {
@@ -756,15 +999,15 @@ function loadAuth() {
756
999
  }
757
1000
  function saveAuth(auth) {
758
1001
  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`);
1002
+ const dir = path5.dirname(filePath);
1003
+ fs5.mkdirSync(dir, { recursive: true });
1004
+ const tmpPath = path5.join(dir, `.auth-${crypto.randomBytes(8).toString("hex")}.tmp`);
762
1005
  try {
763
- fs3.writeFileSync(tmpPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 384 });
764
- fs3.renameSync(tmpPath, filePath);
1006
+ fs5.writeFileSync(tmpPath, JSON.stringify(auth, null, 2), { encoding: "utf-8", mode: 384 });
1007
+ fs5.renameSync(tmpPath, filePath);
765
1008
  } catch (err) {
766
1009
  try {
767
- fs3.unlinkSync(tmpPath);
1010
+ fs5.unlinkSync(tmpPath);
768
1011
  } catch {
769
1012
  }
770
1013
  throw err;
@@ -773,7 +1016,7 @@ function saveAuth(auth) {
773
1016
  function deleteAuth() {
774
1017
  const filePath = getAuthFilePath();
775
1018
  try {
776
- fs3.unlinkSync(filePath);
1019
+ fs5.unlinkSync(filePath);
777
1020
  } catch (err) {
778
1021
  if (err.code !== "ENOENT") {
779
1022
  throw err;
@@ -1043,27 +1286,27 @@ var ApiClient = class {
1043
1286
  clearTimeout(timer);
1044
1287
  }
1045
1288
  }
1046
- async get(path7) {
1047
- this.log(`GET ${path7}`);
1048
- const res = await this.timedFetch(`${this.baseUrl}${path7}`, {
1289
+ async get(path9) {
1290
+ this.log(`GET ${path9}`);
1291
+ const res = await this.timedFetch(`${this.baseUrl}${path9}`, {
1049
1292
  method: "GET",
1050
1293
  headers: this.headers()
1051
1294
  });
1052
- return this.handleResponse(res, path7, "GET");
1295
+ return this.handleResponse(res, path9, "GET");
1053
1296
  }
1054
- async post(path7, body) {
1055
- this.log(`POST ${path7}`);
1056
- const res = await this.timedFetch(`${this.baseUrl}${path7}`, {
1297
+ async post(path9, body) {
1298
+ this.log(`POST ${path9}`);
1299
+ const res = await this.timedFetch(`${this.baseUrl}${path9}`, {
1057
1300
  method: "POST",
1058
1301
  headers: this.headers(),
1059
1302
  body: body !== void 0 ? JSON.stringify(body) : void 0
1060
1303
  });
1061
- return this.handleResponse(res, path7, "POST", body);
1304
+ return this.handleResponse(res, path9, "POST", body);
1062
1305
  }
1063
- async handleResponse(res, path7, method, body) {
1306
+ async handleResponse(res, path9, method, body) {
1064
1307
  if (!res.ok) {
1065
1308
  const { message, errorCode, minimumVersion } = await this.parseErrorBody(res);
1066
- this.log(`${res.status} ${message} (${path7})`);
1309
+ this.log(`${res.status} ${message} (${path9})`);
1067
1310
  if (res.status === 426) {
1068
1311
  throw new UpgradeRequiredError(this.cliVersion ?? "unknown", minimumVersion);
1069
1312
  }
@@ -1072,12 +1315,12 @@ var ApiClient = class {
1072
1315
  try {
1073
1316
  this.authToken = await this.onTokenRefresh();
1074
1317
  this.log("Token refreshed, retrying request");
1075
- const retryRes = await this.timedFetch(`${this.baseUrl}${path7}`, {
1318
+ const retryRes = await this.timedFetch(`${this.baseUrl}${path9}`, {
1076
1319
  method,
1077
1320
  headers: this.headers(),
1078
1321
  body: body !== void 0 ? JSON.stringify(body) : void 0
1079
1322
  });
1080
- return this.handleRetryResponse(retryRes, path7);
1323
+ return this.handleRetryResponse(retryRes, path9);
1081
1324
  } catch (refreshErr) {
1082
1325
  this.log(`Token refresh failed: ${refreshErr.message}`);
1083
1326
  throw new HttpError(res.status, message, errorCode);
@@ -1085,20 +1328,20 @@ var ApiClient = class {
1085
1328
  }
1086
1329
  throw new HttpError(res.status, message, errorCode);
1087
1330
  }
1088
- this.log(`${res.status} OK (${path7})`);
1331
+ this.log(`${res.status} OK (${path9})`);
1089
1332
  return await res.json();
1090
1333
  }
1091
1334
  /** Handle response for a retry after token refresh — no second refresh attempt. */
1092
- async handleRetryResponse(res, path7) {
1335
+ async handleRetryResponse(res, path9) {
1093
1336
  if (!res.ok) {
1094
1337
  const { message, errorCode, minimumVersion } = await this.parseErrorBody(res);
1095
- this.log(`${res.status} ${message} (${path7}) [retry]`);
1338
+ this.log(`${res.status} ${message} (${path9}) [retry]`);
1096
1339
  if (res.status === 426) {
1097
1340
  throw new UpgradeRequiredError(this.cliVersion ?? "unknown", minimumVersion);
1098
1341
  }
1099
1342
  throw new HttpError(res.status, message, errorCode);
1100
1343
  }
1101
- this.log(`${res.status} OK (${path7}) [retry]`);
1344
+ this.log(`${res.status} OK (${path9}) [retry]`);
1102
1345
  return await res.json();
1103
1346
  }
1104
1347
  };
@@ -1153,9 +1396,9 @@ function sleep(ms, signal) {
1153
1396
  }
1154
1397
 
1155
1398
  // 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";
1399
+ import { spawn, execFileSync as execFileSync3 } from "child_process";
1400
+ import * as fs6 from "fs";
1401
+ import * as path6 from "path";
1159
1402
  var ToolTimeoutError = class extends Error {
1160
1403
  constructor(message) {
1161
1404
  super(message);
@@ -1167,9 +1410,9 @@ var MIN_PARTIAL_RESULT_LENGTH = 50;
1167
1410
  var MAX_STDERR_LENGTH = 1e3;
1168
1411
  function validateCommandBinary(commandTemplate) {
1169
1412
  const { command } = parseCommandTemplate(commandTemplate);
1170
- if (path4.isAbsolute(command)) {
1413
+ if (path6.isAbsolute(command)) {
1171
1414
  try {
1172
- fs4.accessSync(command, fs4.constants.X_OK);
1415
+ fs6.accessSync(command, fs6.constants.X_OK);
1173
1416
  return true;
1174
1417
  } catch {
1175
1418
  return false;
@@ -1178,9 +1421,9 @@ function validateCommandBinary(commandTemplate) {
1178
1421
  try {
1179
1422
  const isWindows = process.platform === "win32";
1180
1423
  if (isWindows) {
1181
- execFileSync2("where", [command], { stdio: "pipe" });
1424
+ execFileSync3("where", [command], { stdio: "pipe" });
1182
1425
  } else {
1183
- execFileSync2("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
1426
+ execFileSync3("sh", ["-c", 'command -v -- "$1"', "_", command], { stdio: "pipe" });
1184
1427
  }
1185
1428
  return true;
1186
1429
  } catch {
@@ -1537,7 +1780,10 @@ ${userMessage}`;
1537
1780
  verdict,
1538
1781
  tokensUsed: result.tokensUsed + inputTokens,
1539
1782
  tokensEstimated: !result.tokensParsed,
1540
- tokenDetail
1783
+ tokenDetail,
1784
+ toolStdout: result.stdout,
1785
+ toolStderr: result.stderr,
1786
+ promptLength: fullPrompt.length
1541
1787
  };
1542
1788
  } finally {
1543
1789
  clearTimeout(abortTimer);
@@ -1716,7 +1962,10 @@ ${userMessage}`;
1716
1962
  tokensUsed: result.tokensUsed + inputTokens,
1717
1963
  tokensEstimated: !result.tokensParsed,
1718
1964
  tokenDetail,
1719
- flaggedReviews
1965
+ flaggedReviews,
1966
+ toolStdout: result.stdout,
1967
+ toolStderr: result.stderr,
1968
+ promptLength: fullPrompt.length
1720
1969
  };
1721
1970
  } finally {
1722
1971
  clearTimeout(abortTimer);
@@ -1914,9 +2163,9 @@ function formatPostReviewStats(session) {
1914
2163
  }
1915
2164
 
1916
2165
  // 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");
2166
+ import * as fs7 from "fs";
2167
+ import * as path7 from "path";
2168
+ var USAGE_FILE = path7.join(CONFIG_DIR, "usage.json");
1920
2169
  var MAX_HISTORY_DAYS = 30;
1921
2170
  var WARNING_THRESHOLD = 0.8;
1922
2171
  function todayKey() {
@@ -1939,8 +2188,8 @@ var UsageTracker = class {
1939
2188
  }
1940
2189
  load() {
1941
2190
  try {
1942
- if (fs5.existsSync(this.filePath)) {
1943
- const raw = fs5.readFileSync(this.filePath, "utf-8");
2191
+ if (fs7.existsSync(this.filePath)) {
2192
+ const raw = fs7.readFileSync(this.filePath, "utf-8");
1944
2193
  const parsed = JSON.parse(raw);
1945
2194
  if (parsed && Array.isArray(parsed.days)) {
1946
2195
  return parsed;
@@ -1952,7 +2201,7 @@ var UsageTracker = class {
1952
2201
  }
1953
2202
  save() {
1954
2203
  ensureConfigDir();
1955
- fs5.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), {
2204
+ fs7.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), {
1956
2205
  encoding: "utf-8",
1957
2206
  mode: 384
1958
2207
  });
@@ -2156,6 +2405,36 @@ function createLogger(label) {
2156
2405
  logWarn: (msg) => console.warn(`${pc.dim(`[${timestamp()}]`)}${labelStr} ${pc.yellow(sanitizeTokens(msg))}`)
2157
2406
  };
2158
2407
  }
2408
+ var VERBOSE_TRUNCATE_LIMIT = 2e3;
2409
+ var CHARS_PER_TOKEN_ESTIMATE = 4;
2410
+ function logVerboseToolOutput(logger, label, stdout, stderr, promptLength, limit = VERBOSE_TRUNCATE_LIMIT) {
2411
+ const estimatedTokens = Math.ceil(promptLength / CHARS_PER_TOKEN_ESTIMATE);
2412
+ logger.log(
2413
+ `${icons.info} [verbose] ${label} \u2014 prompt: ${promptLength} chars (~${estimatedTokens} tokens)`
2414
+ );
2415
+ if (stdout) {
2416
+ const truncated = stdout.length > limit ? stdout.slice(0, limit) + `
2417
+ ... (truncated at ${limit} chars)` : stdout;
2418
+ logger.log(
2419
+ `${icons.info} [verbose] ${label} stdout (${stdout.length} chars):
2420
+ ---
2421
+ ${truncated}
2422
+ ---`
2423
+ );
2424
+ } else {
2425
+ logger.log(`${icons.info} [verbose] ${label} stdout: (empty)`);
2426
+ }
2427
+ if (stderr) {
2428
+ const truncated = stderr.length > limit ? stderr.slice(0, limit) + `
2429
+ ... (truncated at ${limit} chars)` : stderr;
2430
+ logger.log(
2431
+ `${icons.info} [verbose] ${label} stderr (${stderr.length} chars):
2432
+ ---
2433
+ ${truncated}
2434
+ ---`
2435
+ );
2436
+ }
2437
+ }
2159
2438
  function createAgentSession() {
2160
2439
  return {
2161
2440
  startTime: Date.now(),
@@ -2926,7 +3205,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2926
3205
  repoConfig,
2927
3206
  roles,
2928
3207
  synthesizeRepos,
2929
- signal
3208
+ signal,
3209
+ cleanupTracker,
3210
+ verbose
2930
3211
  } = options;
2931
3212
  const { log, logError, logWarn } = logger;
2932
3213
  log(`${icons.polling} Polling every ${pollIntervalMs / 1e3}s...`);
@@ -2962,6 +3243,14 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2962
3243
  const task = eligibleTasks.find(
2963
3244
  (t) => (diffFailCounts.get(t.task_id) ?? 0) < MAX_DIFF_FETCH_ATTEMPTS
2964
3245
  );
3246
+ if (cleanupTracker) {
3247
+ const swept = await cleanupTracker.sweep(cleanupWorktree);
3248
+ if (swept > 0) {
3249
+ log(
3250
+ `${icons.info} Cleaned up ${swept} stale codebase director${swept === 1 ? "y" : "ies"}`
3251
+ );
3252
+ }
3253
+ }
2965
3254
  if (task) {
2966
3255
  const result = await handleTask(
2967
3256
  client,
@@ -2973,7 +3262,9 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
2973
3262
  logger,
2974
3263
  agentSession,
2975
3264
  routerRelay,
2976
- signal
3265
+ signal,
3266
+ cleanupTracker,
3267
+ verbose
2977
3268
  );
2978
3269
  if (result.diffFetchFailed) {
2979
3270
  agentSession.errorsEncountered++;
@@ -3031,7 +3322,7 @@ async function pollLoop(client, agentId, reviewDeps, consumptionDeps, agentInfo,
3031
3322
  await sleep2(pollIntervalMs, signal);
3032
3323
  }
3033
3324
  }
3034
- async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal) {
3325
+ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, agentInfo, logger, agentSession, routerRelay, signal, cleanupTracker, verbose) {
3035
3326
  const { task_id, owner, repo, pr_number, diff_url, timeout_seconds, prompt, role } = task;
3036
3327
  const { log, logError, logWarn } = logger;
3037
3328
  const isIssueTask = pr_number === 0;
@@ -3068,6 +3359,7 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3068
3359
  let diffContent = "";
3069
3360
  let taskReviewDeps = reviewDeps;
3070
3361
  let taskCheckoutPath = null;
3362
+ let taskBareRepoPath = null;
3071
3363
  let contextBlock;
3072
3364
  if (isIssueTask) {
3073
3365
  log(" Issue-based task \u2014 skipping diff fetch");
@@ -3091,33 +3383,20 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3091
3383
  );
3092
3384
  return { diffFetchFailed: true };
3093
3385
  }
3094
- if (reviewDeps.codebaseDir) {
3386
+ {
3387
+ const codebaseDir = reviewDeps.codebaseDir || path8.join(CONFIG_DIR, "repos");
3095
3388
  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 };
3389
+ const result = await checkoutWorktree(owner, repo, pr_number, codebaseDir, task_id);
3390
+ log(` Codebase ${result.cloned ? "cloned" : "cached"} \u2192 worktree: ${result.worktreePath}`);
3391
+ taskCheckoutPath = result.worktreePath;
3392
+ taskBareRepoPath = result.bareRepoPath;
3393
+ taskReviewDeps = { ...reviewDeps, codebaseDir: result.worktreePath };
3100
3394
  } catch (err) {
3101
3395
  logWarn(
3102
- ` Warning: codebase clone failed: ${err.message}. Continuing with diff-only review.`
3396
+ ` Warning: worktree checkout failed: ${err.message}. Continuing with diff-only review.`
3103
3397
  );
3104
3398
  taskReviewDeps = { ...reviewDeps, codebaseDir: null };
3105
3399
  }
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
3400
  }
3122
3401
  try {
3123
3402
  const prContext = await fetchPRContext(owner, repo, pr_number, {
@@ -3219,7 +3498,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3219
3498
  agentInfo,
3220
3499
  routerRelay,
3221
3500
  signal,
3222
- contextBlock
3501
+ contextBlock,
3502
+ verbose
3223
3503
  );
3224
3504
  } else {
3225
3505
  await executeReviewTask(
@@ -3238,7 +3518,8 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3238
3518
  agentInfo,
3239
3519
  routerRelay,
3240
3520
  signal,
3241
- contextBlock
3521
+ contextBlock,
3522
+ verbose
3242
3523
  );
3243
3524
  }
3244
3525
  agentSession.tasksCompleted++;
@@ -3252,8 +3533,12 @@ async function handleTask(client, agentId, task, reviewDeps, consumptionDeps, ag
3252
3533
  await safeError(client, task_id, agentId, err.message, logger);
3253
3534
  }
3254
3535
  } finally {
3255
- if (taskCheckoutPath) {
3256
- cleanupTaskDir(taskCheckoutPath);
3536
+ if (taskCheckoutPath && taskBareRepoPath) {
3537
+ if (cleanupTracker) {
3538
+ cleanupTracker.track(taskBareRepoPath, taskCheckoutPath);
3539
+ } else {
3540
+ await cleanupWorktree(taskBareRepoPath, taskCheckoutPath);
3541
+ }
3257
3542
  }
3258
3543
  }
3259
3544
  return {};
@@ -3288,7 +3573,7 @@ async function safeError(client, taskId, agentId, error, logger) {
3288
3573
  );
3289
3574
  }
3290
3575
  }
3291
- async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock) {
3576
+ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock, verbose) {
3292
3577
  if (consumptionDeps.usageLimits?.maxTokensPerReview != null && consumptionDeps.usageTracker) {
3293
3578
  const estimatedInput = estimateTokens(diffContent + prompt + (contextBlock ?? ""));
3294
3579
  const perReviewCheck = consumptionDeps.usageTracker.checkPerReviewLimit(
@@ -3354,6 +3639,15 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
3354
3639
  totalTokens: result.tokensUsed,
3355
3640
  estimated: result.tokensEstimated
3356
3641
  };
3642
+ if (verbose) {
3643
+ logVerboseToolOutput(
3644
+ logger,
3645
+ "Review",
3646
+ result.toolStdout,
3647
+ result.toolStderr,
3648
+ result.promptLength
3649
+ );
3650
+ }
3357
3651
  }
3358
3652
  const reviewMeta = {
3359
3653
  model: agentInfo.model,
@@ -3383,7 +3677,7 @@ async function executeReviewTask(client, agentId, taskId, owner, repo, prNumber,
3383
3677
  logger.log(` ${icons.success} Review submitted (${tokensUsed.toLocaleString()} tokens)`);
3384
3678
  logger.log(formatPostReviewStats(consumptionDeps.session));
3385
3679
  }
3386
- async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock) {
3680
+ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber, diffContent, prompt, timeoutSeconds, reviews, reviewDeps, consumptionDeps, logger, agentInfo, routerRelay, signal, contextBlock, verbose) {
3387
3681
  const meta = { model: agentInfo.model, tool: agentInfo.tool };
3388
3682
  if (reviews.length === 0) {
3389
3683
  let reviewText;
@@ -3441,6 +3735,15 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
3441
3735
  totalTokens: result.tokensUsed,
3442
3736
  estimated: result.tokensEstimated
3443
3737
  };
3738
+ if (verbose) {
3739
+ logVerboseToolOutput(
3740
+ logger,
3741
+ "Summary (single-agent)",
3742
+ result.toolStdout,
3743
+ result.toolStderr,
3744
+ result.promptLength
3745
+ );
3746
+ }
3444
3747
  }
3445
3748
  const headerSingle = buildMetadataHeader(verdict ?? "comment", meta);
3446
3749
  const sanitizedReview = sanitizeTokens(headerSingle + reviewText);
@@ -3534,6 +3837,15 @@ async function executeSummaryTask(client, agentId, taskId, owner, repo, prNumber
3534
3837
  totalTokens: result.tokensUsed,
3535
3838
  estimated: result.tokensEstimated
3536
3839
  };
3840
+ if (verbose) {
3841
+ logVerboseToolOutput(
3842
+ logger,
3843
+ "Summary",
3844
+ result.toolStdout,
3845
+ result.toolStderr,
3846
+ result.promptLength
3847
+ );
3848
+ }
3537
3849
  }
3538
3850
  if (flaggedReviews.length > 0) {
3539
3851
  logger.logWarn(
@@ -3592,7 +3904,7 @@ function sleep2(ms, signal) {
3592
3904
  async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumptionDeps, options) {
3593
3905
  const client = new ApiClient(platformUrl, {
3594
3906
  authToken: options?.authToken,
3595
- cliVersion: "0.18.3",
3907
+ cliVersion: "0.18.5",
3596
3908
  versionOverride: options?.versionOverride,
3597
3909
  onTokenRefresh: options?.onTokenRefresh
3598
3910
  });
@@ -3617,6 +3929,9 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3617
3929
  if (options?.versionOverride) {
3618
3930
  log(`${icons.info} Version override active: ${options.versionOverride}`);
3619
3931
  }
3932
+ if (options?.verbose) {
3933
+ log(`${icons.info} Verbose mode enabled \u2014 tool stdout/stderr will be logged`);
3934
+ }
3620
3935
  if (!reviewDeps) {
3621
3936
  logError(`${icons.error} No review command configured. Set command in config.toml`);
3622
3937
  return;
@@ -3630,6 +3945,16 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3630
3945
  logWarn(`${icons.warn} Command test failed (${result.error}). Reviews may fail.`);
3631
3946
  }
3632
3947
  }
3948
+ const ttlMs = options?.codebaseTtl != null ? parseTtl(options.codebaseTtl) : 0;
3949
+ const codebaseDir = reviewDeps.codebaseDir || path8.join(CONFIG_DIR, "repos");
3950
+ const scanTtl = Math.max(ttlMs, DEFAULT_CODEBASE_TTL_MS);
3951
+ const staleCount = scanAndCleanStaleWorktrees(codebaseDir, scanTtl);
3952
+ if (staleCount > 0) {
3953
+ log(
3954
+ `${icons.info} Cleaned up ${staleCount} stale codebase director${staleCount === 1 ? "y" : "ies"} on startup`
3955
+ );
3956
+ }
3957
+ const cleanupTracker = ttlMs > 0 ? new CodebaseCleanupTracker(ttlMs) : void 0;
3633
3958
  const abortController = new AbortController();
3634
3959
  process.on("SIGINT", () => {
3635
3960
  abortController.abort();
@@ -3645,8 +3970,18 @@ async function startAgent(agentId, platformUrl, agentInfo, reviewDeps, consumpti
3645
3970
  repoConfig: options?.repoConfig,
3646
3971
  roles: options?.roles,
3647
3972
  synthesizeRepos: options?.synthesizeRepos,
3648
- signal: abortController.signal
3973
+ signal: abortController.signal,
3974
+ cleanupTracker,
3975
+ verbose: options?.verbose
3649
3976
  });
3977
+ if (cleanupTracker && cleanupTracker.size > 0) {
3978
+ const finalSwept = await cleanupTracker.sweep(cleanupWorktree);
3979
+ if (finalSwept > 0) {
3980
+ log(
3981
+ `${icons.info} Cleaned up ${finalSwept} codebase director${finalSwept === 1 ? "y" : "ies"} on shutdown`
3982
+ );
3983
+ }
3984
+ }
3650
3985
  if (deps.usageTracker) {
3651
3986
  log(deps.usageTracker.formatSummary(deps.usageLimits ?? usageLimits));
3652
3987
  }
@@ -3718,13 +4053,13 @@ async function startAgentRouter() {
3718
4053
  authToken: oauthToken,
3719
4054
  onTokenRefresh: () => getValidToken(config.platformUrl),
3720
4055
  usageLimits: config.usageLimits,
3721
- versionOverride
4056
+ versionOverride,
4057
+ codebaseTtl: config.codebaseTtl
3722
4058
  }
3723
4059
  );
3724
4060
  router.stop();
3725
4061
  }
3726
- function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versionOverride) {
3727
- const agentId = crypto2.randomUUID();
4062
+ function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versionOverride, verbose, instancesOverride) {
3728
4063
  let commandTemplate;
3729
4064
  let agentConfig;
3730
4065
  if (config.agents && config.agents.length > agentIndex) {
@@ -3744,58 +4079,76 @@ function startAgentByIndex(config, agentIndex, pollIntervalMs, oauthToken, versi
3744
4079
  );
3745
4080
  return null;
3746
4081
  }
4082
+ const instanceCount = instancesOverride ?? agentConfig?.instances ?? 1;
3747
4083
  const codebaseDir = resolveCodebaseDir(agentConfig?.codebase_dir, config.codebaseDir);
3748
4084
  const reviewDeps = {
3749
4085
  commandTemplate,
3750
4086
  maxDiffSizeKb: config.maxDiffSizeKb,
3751
4087
  codebaseDir
3752
4088
  };
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
4089
  const model = agentConfig?.model ?? "unknown";
3762
4090
  const tool = agentConfig?.tool ?? "unknown";
3763
4091
  const thinking = agentConfig?.thinking;
3764
4092
  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
4093
+ const session = createSessionTracker();
4094
+ const usageTracker = new UsageTracker();
4095
+ const promises = [];
4096
+ for (let inst = 0; inst < instanceCount; inst++) {
4097
+ const agentId = crypto2.randomUUID();
4098
+ const instanceLabel = instanceCount > 1 ? `${label}#${inst + 1}` : label;
4099
+ const isRouter = agentConfig?.router === true;
4100
+ let routerRelay;
4101
+ if (isRouter) {
4102
+ routerRelay = new RouterRelay();
4103
+ routerRelay.start();
3784
4104
  }
3785
- ).finally(() => {
3786
- routerRelay?.stop();
3787
- });
3788
- return agentPromise;
4105
+ const agentPromise = startAgent(
4106
+ agentId,
4107
+ config.platformUrl,
4108
+ { model, tool, thinking },
4109
+ reviewDeps,
4110
+ { agentId, session, usageTracker, usageLimits: config.usageLimits },
4111
+ {
4112
+ pollIntervalMs,
4113
+ maxConsecutiveErrors: config.maxConsecutiveErrors,
4114
+ routerRelay,
4115
+ reviewOnly: agentConfig?.review_only,
4116
+ repoConfig: agentConfig?.repos,
4117
+ roles,
4118
+ synthesizeRepos: agentConfig?.synthesize_repos,
4119
+ label: instanceLabel,
4120
+ authToken: oauthToken,
4121
+ onTokenRefresh: () => getValidToken(config.platformUrl),
4122
+ usageLimits: config.usageLimits,
4123
+ versionOverride,
4124
+ codebaseTtl: config.codebaseTtl,
4125
+ verbose
4126
+ }
4127
+ ).finally(() => {
4128
+ routerRelay?.stop();
4129
+ });
4130
+ promises.push(agentPromise);
4131
+ }
4132
+ return promises;
3789
4133
  }
3790
4134
  var agentCommand = new Command("agent").description("Manage review agents");
3791
4135
  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
4136
  "--version-override <value>",
3793
4137
  "Cloudflare Workers version override (e.g. opencara-server=abc123)"
3794
- ).action(
4138
+ ).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
4139
  async (opts) => {
3796
4140
  const config = loadConfig();
3797
4141
  const pollIntervalMs = parseInt(opts.pollInterval, 10) * 1e3;
3798
4142
  const versionOverride = opts.versionOverride || process.env.OPENCARA_VERSION_OVERRIDE || null;
4143
+ let instancesOverride;
4144
+ if (opts.instances !== void 0) {
4145
+ if (!/^[1-9]\d*$/.test(opts.instances)) {
4146
+ console.error("--instances must be a positive integer");
4147
+ process.exit(1);
4148
+ return;
4149
+ }
4150
+ instancesOverride = parseInt(opts.instances, 10);
4151
+ }
3799
4152
  let oauthToken;
3800
4153
  try {
3801
4154
  oauthToken = await getValidToken(config.platformUrl);
@@ -3817,13 +4170,21 @@ agentCommand.command("start").description("Start agents in polling mode").option
3817
4170
  process.exit(1);
3818
4171
  return;
3819
4172
  }
3820
- console.log(`Starting ${config.agents.length} agent(s)...`);
4173
+ console.log(`Starting ${config.agents.length} agent config(s)...`);
3821
4174
  const promises = [];
3822
4175
  let startFailed = false;
3823
4176
  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);
4177
+ const agentPromises = startAgentByIndex(
4178
+ config,
4179
+ i,
4180
+ pollIntervalMs,
4181
+ oauthToken,
4182
+ versionOverride,
4183
+ opts.verbose,
4184
+ instancesOverride
4185
+ );
4186
+ if (agentPromises) {
4187
+ promises.push(...agentPromises);
3827
4188
  } else {
3828
4189
  startFailed = true;
3829
4190
  }
@@ -3838,7 +4199,7 @@ agentCommand.command("start").description("Start agents in polling mode").option
3838
4199
  "One or more agents could not start (see warnings above). Continuing with the rest."
3839
4200
  );
3840
4201
  }
3841
- console.log(`${promises.length} agent(s) running. Press Ctrl+C to stop all.
4202
+ console.log(`${promises.length} agent instance(s) running. Press Ctrl+C to stop all.
3842
4203
  `);
3843
4204
  const results = await Promise.allSettled(promises);
3844
4205
  const failures = results.filter((r) => r.status === "rejected");
@@ -3858,18 +4219,27 @@ agentCommand.command("start").description("Start agents in polling mode").option
3858
4219
  process.exit(1);
3859
4220
  return;
3860
4221
  }
3861
- const p = startAgentByIndex(
4222
+ const agentPromises = startAgentByIndex(
3862
4223
  config,
3863
4224
  agentIndex,
3864
4225
  pollIntervalMs,
3865
4226
  oauthToken,
3866
- versionOverride
4227
+ versionOverride,
4228
+ opts.verbose,
4229
+ instancesOverride
3867
4230
  );
3868
- if (!p) {
4231
+ if (!agentPromises) {
3869
4232
  process.exit(1);
3870
4233
  return;
3871
4234
  }
3872
- await p;
4235
+ const results = await Promise.allSettled(agentPromises);
4236
+ const failures = results.filter((r) => r.status === "rejected");
4237
+ if (failures.length > 0) {
4238
+ for (const f of failures) {
4239
+ console.error(`Agent instance failed: ${f.reason}`);
4240
+ }
4241
+ process.exit(1);
4242
+ }
3873
4243
  }
3874
4244
  }
3875
4245
  );
@@ -4005,8 +4375,8 @@ var PER_PAGE = 100;
4005
4375
  var OPEN_MARKER = "<!-- opencara-dedup-index:open -->";
4006
4376
  var RECENT_MARKER = "<!-- opencara-dedup-index:recent -->";
4007
4377
  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}`;
4378
+ async function fetchRepoFile(owner, repo, path9, token, fetchFn = fetch) {
4379
+ const url = `https://api.github.com/repos/${owner}/${repo}/contents/${path9}`;
4010
4380
  const res = await fetchFn(url, {
4011
4381
  headers: {
4012
4382
  Authorization: `Bearer ${token}`,
@@ -4014,7 +4384,7 @@ async function fetchRepoFile(owner, repo, path7, token, fetchFn = fetch) {
4014
4384
  }
4015
4385
  });
4016
4386
  if (res.status === 404) return null;
4017
- if (!res.ok) throw new Error(`GitHub API error: ${res.status} fetching ${path7}`);
4387
+ if (!res.ok) throw new Error(`GitHub API error: ${res.status} fetching ${path9}`);
4018
4388
  return res.text();
4019
4389
  }
4020
4390
  async function fetchAllPRs(owner, repo, token, fetchFn = fetch, log) {
@@ -4572,7 +4942,7 @@ var statusCommand = new Command4("status").description("Show agent config, conne
4572
4942
  });
4573
4943
 
4574
4944
  // src/index.ts
4575
- var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.3");
4945
+ var program = new Command5().name("opencara").description("OpenCara \u2014 distributed AI code review agent").version("0.18.5");
4576
4946
  program.addCommand(agentCommand);
4577
4947
  program.addCommand(authCommand());
4578
4948
  program.addCommand(dedupCommand());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.18.3",
3
+ "version": "0.18.5",
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",
@@ -30,13 +30,10 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "bin": {
33
- "opencara": "dist/index.js",
34
- "opencara-codex-agent": "bin/opencara-codex-agent",
35
- "opencara-gemini-agent": "bin/opencara-gemini-agent"
33
+ "opencara": "dist/index.js"
36
34
  },
37
35
  "files": [
38
36
  "dist",
39
- "bin",
40
37
  "README.md"
41
38
  ],
42
39
  "scripts": {