create-interview-cockpit 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ import {
17
17
  tool,
18
18
  stepCountIs,
19
19
  type LanguageModel,
20
+ type ToolSet,
20
21
  } from "ai";
21
22
  import { z } from "zod";
22
23
  import { createOpenAI } from "@ai-sdk/openai";
@@ -163,6 +164,7 @@ function makeCodeReferenceFileEntry(
163
164
  }
164
165
 
165
166
  interface GitDiffContextPayload {
167
+ rootId?: string;
166
168
  baseRef?: string;
167
169
  headRef?: string;
168
170
  mode?: string;
@@ -176,82 +178,56 @@ async function registerGitDiffContext(
176
178
  fileRegistry: Map<string, ReferenceFileEntry>,
177
179
  ctx: GitDiffContextPayload,
178
180
  ): Promise<string> {
179
- if (!GIT_DIFF_DIR) return "";
180
- const base = (ctx.baseRef || "").trim();
181
- if (!base || !isValidRef(base)) return "";
182
- const mode = parseDiffMode(ctx.mode);
183
- const headRaw = (ctx.headRef || "").trim();
184
- const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
185
- if (mode !== "working-tree" && (!head || !isValidRef(head))) return "";
186
-
187
- const selected = Array.isArray(ctx.selectedFiles)
188
- ? ctx.selectedFiles.filter(
189
- (p) => typeof p === "string" && p && !p.includes("\0"),
190
- )
191
- : [];
192
- if (selected.length === 0) return "";
181
+ const scoped = await getScopedGitDiffContext(ctx);
182
+ if (!scoped) return "";
183
+ const { root, base, head, mode, entries, rangeLabel, headLabel } = scoped;
193
184
 
194
- let changedFiles: GitDiffFileEntry[];
195
- try {
196
- changedFiles = await getChangedFiles(base, head, mode);
197
- } catch {
198
- return "";
199
- }
200
- const byPath = new Map(changedFiles.map((f) => [f.path, f]));
201
- const validSelected = selected.filter((p) => byPath.has(p));
202
- if (validSelected.length === 0) return "";
203
-
204
- const headLabel = mode === "working-tree" ? "working tree" : head;
205
- const rangeLabel =
206
- mode === "working-tree"
207
- ? `${base} → working tree`
208
- : `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
209
-
210
- let manifest = `\n\n--- Available Git Diff Context (${rangeLabel}) ---
211
- You can pull lazy git diff context with the readFile tool. Three views are available per file:
212
- • gitdiff:patch:<path> — unified diff hunks (recommended starting point)
213
- • gitdiff:before:<path> — full file contents at the base ref
214
- • gitdiff:after:<path> — full file contents at the head ref (or working tree)
215
- Only read what you need; each file can be expensive.
185
+ let manifest = `\n\n--- Available Git Diff Context (${root.name}: ${rangeLabel}) ---
186
+ You can inspect the selected changed files with the Git Diff tools or pull lazy views with readFile. Three views are available per file:
187
+ gitdiff:patch:<root>::<path> — unified diff hunks (recommended starting point)
188
+ gitdiff:before:<root>::<path> — full file contents at the base ref
189
+ gitdiff:after:<root>::<path> — full file contents at the head ref (or working tree)
190
+ Use listGitDiffFiles to see selected diff files, readGitDiffFile to get patch/before/after, searchGitDiff to find specific changes, and summarizeGitDiff for totals. Only read what you need; each file can be expensive.
216
191
 
217
192
  `;
218
- for (const filePath of validSelected) {
219
- const entry = byPath.get(filePath)!;
193
+ for (const entry of entries) {
220
194
  const sign = entry.binary
221
195
  ? "binary"
222
196
  : `+${entry.additions}/-${entry.deletions}`;
223
197
  const renamed = entry.oldPath ? ` (renamed from ${entry.oldPath})` : "";
224
- manifest += `• [${entry.status}] ${entry.path} ${sign}${renamed}\n`;
225
- manifest += ` patch id: "gitdiff:patch:${entry.path}"\n`;
198
+ manifest += `• [${entry.status}] ${entry.displayPath} ${sign}${renamed}\n`;
199
+ manifest += ` patch id: "gitdiff:patch:${entry.id}"\n`;
226
200
  if (entry.status !== "A" && entry.status !== "?") {
227
- manifest += ` before id: "gitdiff:before:${entry.path}"\n`;
201
+ manifest += ` before id: "gitdiff:before:${entry.id}"\n`;
228
202
  }
229
203
  if (entry.status !== "D") {
230
- manifest += ` after id: "gitdiff:after:${entry.path}"\n`;
204
+ manifest += ` after id: "gitdiff:after:${entry.id}"\n`;
231
205
  }
232
206
 
233
- const safePath = filePath; // already validated to be in diff
207
+ const safePath = entry.path; // already validated to be in diff
234
208
  fileRegistry.set(
235
- `gitdiff:patch:${safePath}`,
236
- makeCodeReferenceFileEntry(`[git diff] ${safePath}`, () =>
237
- getDiffPatch(base, head, mode, safePath),
209
+ `gitdiff:patch:${entry.id}`,
210
+ makeCodeReferenceFileEntry(`[git diff:${root.name}] ${safePath}`, () =>
211
+ getDiffPatch(root, base, head, mode, safePath),
238
212
  ),
239
213
  );
240
214
  if (entry.status !== "A" && entry.status !== "?") {
241
215
  const refPath = entry.oldPath ?? safePath;
242
216
  fileRegistry.set(
243
- `gitdiff:before:${safePath}`,
244
- makeCodeReferenceFileEntry(`[git before ${base}] ${safePath}`, () =>
245
- getFileAtRef(base, refPath),
217
+ `gitdiff:before:${entry.id}`,
218
+ makeCodeReferenceFileEntry(
219
+ `[git before ${base}:${root.name}] ${safePath}`,
220
+ () => getFileAtRef(root, base, refPath),
246
221
  ),
247
222
  );
248
223
  }
249
224
  if (entry.status !== "D") {
250
225
  const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
251
226
  fileRegistry.set(
252
- `gitdiff:after:${safePath}`,
253
- makeCodeReferenceFileEntry(`[git after ${headLabel}] ${safePath}`, () =>
254
- getFileAtRef(ref, safePath),
227
+ `gitdiff:after:${entry.id}`,
228
+ makeCodeReferenceFileEntry(
229
+ `[git after ${headLabel}:${root.name}] ${safePath}`,
230
+ () => getFileAtRef(root, ref, safePath),
255
231
  ),
256
232
  );
257
233
  }
@@ -343,7 +319,1235 @@ function createReadFileTool(fileRegistry: Map<string, ReferenceFileEntry>) {
343
319
  }
344
320
 
345
321
  const PORT = process.env.PORT || 3001;
346
- const CODE_CONTEXT_DIR = process.env.CODE_CONTEXT_DIR || "";
322
+ const CODE_CONTEXT_ROOT_SEPARATOR = "::";
323
+
324
+ interface CodeContextRootConfig {
325
+ id: string;
326
+ name: string;
327
+ dir: string;
328
+ }
329
+
330
+ interface CodeContextFileRef {
331
+ id: string;
332
+ root: CodeContextRootConfig;
333
+ relPath: string;
334
+ absolutePath: string;
335
+ }
336
+
337
+ function splitCodeContextRootEnv(value: string | undefined): string[] {
338
+ if (!value) return [];
339
+ return value
340
+ .split(/[\n,;]/)
341
+ .map((entry) => entry.trim())
342
+ .filter(Boolean);
343
+ }
344
+
345
+ function parseCodeContextRootSpec(
346
+ raw: string,
347
+ ): { name?: string; dir: string } | null {
348
+ const value = raw.trim();
349
+ if (!value) return null;
350
+ const eq = value.indexOf("=");
351
+ if (eq > 0) {
352
+ const maybeName = value.slice(0, eq).trim();
353
+ const maybeDir = value.slice(eq + 1).trim();
354
+ if (maybeName && maybeDir && !/[\\/]/.test(maybeName)) {
355
+ return { name: maybeName, dir: path.resolve(maybeDir) };
356
+ }
357
+ }
358
+ return { dir: path.resolve(value) };
359
+ }
360
+
361
+ function slugForCodeContextRoot(value: string): string {
362
+ return (
363
+ value
364
+ .toLowerCase()
365
+ .replace(/[^a-z0-9._-]+/g, "-")
366
+ .replace(/^-+|-+$/g, "") || "root"
367
+ );
368
+ }
369
+
370
+ function uniqueCodeContextRootName(
371
+ baseName: string,
372
+ usedNames: Map<string, number>,
373
+ ): string {
374
+ const clean = baseName.trim() || "Code Context";
375
+ const key = clean.toLowerCase();
376
+ const count = usedNames.get(key) ?? 0;
377
+ usedNames.set(key, count + 1);
378
+ return count === 0 ? clean : `${clean} (${count + 1})`;
379
+ }
380
+
381
+ function uniqueCodeContextRootId(baseId: string, usedIds: Set<string>): string {
382
+ let candidate = baseId;
383
+ let suffix = 2;
384
+ while (usedIds.has(candidate)) {
385
+ candidate = `${baseId}-${suffix}`;
386
+ suffix += 1;
387
+ }
388
+ usedIds.add(candidate);
389
+ return candidate;
390
+ }
391
+
392
+ function resolveCodeContextRoots(): CodeContextRootConfig[] {
393
+ const specs = [
394
+ ...(process.env.CODE_CONTEXT_DIR
395
+ ? [
396
+ process.env.CODE_CONTEXT_NAME
397
+ ? `${process.env.CODE_CONTEXT_NAME}=${process.env.CODE_CONTEXT_DIR}`
398
+ : process.env.CODE_CONTEXT_DIR,
399
+ ]
400
+ : []),
401
+ ...splitCodeContextRootEnv(process.env.CODE_CONTEXT_DIRS),
402
+ ];
403
+ const seenDirs = new Set<string>();
404
+ const usedNames = new Map<string, number>();
405
+ const usedIds = new Set<string>();
406
+ const roots: CodeContextRootConfig[] = [];
407
+
408
+ for (const spec of specs) {
409
+ const parsed = parseCodeContextRootSpec(spec);
410
+ if (!parsed) continue;
411
+ const dir = path.resolve(parsed.dir);
412
+ if (seenDirs.has(dir)) continue;
413
+ seenDirs.add(dir);
414
+ const fallbackName = path.basename(dir) || "Code Context";
415
+ const name = uniqueCodeContextRootName(
416
+ parsed.name || fallbackName,
417
+ usedNames,
418
+ );
419
+ const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
420
+ roots.push({ id, name, dir });
421
+ }
422
+
423
+ return roots;
424
+ }
425
+
426
+ const CODE_CONTEXT_ROOTS = resolveCodeContextRoots();
427
+ const CODE_CONTEXT_ROOT_BY_ID = new Map(
428
+ CODE_CONTEXT_ROOTS.map((root) => [root.id, root]),
429
+ );
430
+ // Backward-compatible primary root used by git-diff config when GIT_DIFF_DIR is unset.
431
+ const CODE_CONTEXT_DIR = CODE_CONTEXT_ROOTS[0]?.dir ?? "";
432
+
433
+ function makeCodeContextFileId(rootId: string, relPath: string): string {
434
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
435
+ }
436
+
437
+ function getCodeContextPrimaryRoot(): CodeContextRootConfig | null {
438
+ return CODE_CONTEXT_ROOTS[0] ?? null;
439
+ }
440
+
441
+ const MAX_CODE_TOOL_LIST_RESULTS = 400;
442
+ const MAX_CODE_TOOL_SEARCH_RESULTS = 80;
443
+ const MAX_CODE_TOOL_SEARCH_FILE_BYTES = 512 * 1024;
444
+ const MAX_CODE_TOOL_READ_BYTES = 256 * 1024;
445
+ const MAX_CODE_TOOL_CONTEXT_LINES = 5;
446
+ const MAX_CODE_TOOL_LINE_CHARS = 600;
447
+
448
+ function normalizeCodeContextRelPath(value: string): string | null {
449
+ const raw = value.replaceAll("\\", "/").trim();
450
+ if (!raw || raw.includes("\0") || path.isAbsolute(raw)) return null;
451
+ const normalized = path.posix.normalize(raw.replace(/^\.\//, ""));
452
+ if (!normalized || normalized === ".") return null;
453
+ if (normalized === ".." || normalized.startsWith("../")) return null;
454
+ return normalized;
455
+ }
456
+
457
+ function getCodeContextFileRef(
458
+ fileIdOrPath: string,
459
+ ): CodeContextFileRef | null {
460
+ if (CODE_CONTEXT_ROOTS.length === 0) return null;
461
+ const raw = fileIdOrPath.replaceAll("\\", "/").trim();
462
+ if (!raw || raw.includes("\0")) return null;
463
+
464
+ const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
465
+ const root =
466
+ sepIdx > 0
467
+ ? CODE_CONTEXT_ROOT_BY_ID.get(raw.slice(0, sepIdx))
468
+ : getCodeContextPrimaryRoot();
469
+ const relRaw =
470
+ sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
471
+ if (!root) return null;
472
+
473
+ const normalized = normalizeCodeContextRelPath(relRaw);
474
+ if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
475
+
476
+ const rootDir = path.resolve(root.dir);
477
+ const resolved = path.resolve(rootDir, normalized);
478
+ const relativeToRoot = path.relative(rootDir, resolved);
479
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
480
+ return null;
481
+ }
482
+ return {
483
+ id: makeCodeContextFileId(root.id, normalized),
484
+ root,
485
+ relPath: normalized,
486
+ absolutePath: resolved,
487
+ };
488
+ }
489
+
490
+ function resolveCodeContextFilePath(fileIdOrPath: string): string | null {
491
+ return getCodeContextFileRef(fileIdOrPath)?.absolutePath ?? null;
492
+ }
493
+
494
+ function codeContextRelPath(fileIdOrPath: string): string {
495
+ return getCodeContextFileRef(fileIdOrPath)?.relPath ?? fileIdOrPath;
496
+ }
497
+
498
+ function codeContextDisplayPath(fileIdOrPath: string): string {
499
+ const ref = getCodeContextFileRef(fileIdOrPath);
500
+ if (!ref) return fileIdOrPath;
501
+ return `${ref.root.name}/${ref.relPath}`;
502
+ }
503
+
504
+ function codeContextFilterText(fileIdOrPath: string): string {
505
+ const ref = getCodeContextFileRef(fileIdOrPath);
506
+ if (!ref) return fileIdOrPath;
507
+ return `${ref.id} ${ref.root.name} ${ref.relPath}`;
508
+ }
509
+
510
+ async function getVisibleFilesForCodeContextRoot(
511
+ root: CodeContextRootConfig,
512
+ ): Promise<Set<string>> {
513
+ try {
514
+ return new Set(await walkDir(root.dir));
515
+ } catch {
516
+ return new Set();
517
+ }
518
+ }
519
+
520
+ async function getCodeContextRootsTree() {
521
+ const roots = [];
522
+ for (const root of CODE_CONTEXT_ROOTS) {
523
+ const files = [...(await getVisibleFilesForCodeContextRoot(root))].sort(
524
+ (a, b) => a.localeCompare(b),
525
+ );
526
+ roots.push({ id: root.id, name: root.name, files });
527
+ }
528
+ return { roots };
529
+ }
530
+
531
+ async function getScopedCodeContextFiles(
532
+ selectedFiles: unknown,
533
+ ): Promise<string[]> {
534
+ if (CODE_CONTEXT_ROOTS.length === 0 || !Array.isArray(selectedFiles)) {
535
+ return [];
536
+ }
537
+
538
+ const selected = selectedFiles
539
+ .filter((file): file is string => typeof file === "string")
540
+ .map((file) => getCodeContextFileRef(file))
541
+ .filter((file): file is CodeContextFileRef => Boolean(file));
542
+
543
+ if (selected.length === 0) return [];
544
+
545
+ const visibleByRoot = new Map<string, Set<string>>();
546
+ for (const root of CODE_CONTEXT_ROOTS.filter((root) =>
547
+ selected.some((file) => file.root.id === root.id),
548
+ )) {
549
+ visibleByRoot.set(root.id, await getVisibleFilesForCodeContextRoot(root));
550
+ }
551
+
552
+ return uniqueStrings(
553
+ selected
554
+ .filter((file) => visibleByRoot.get(file.root.id)?.has(file.relPath))
555
+ .map((file) => file.id),
556
+ );
557
+ }
558
+
559
+ function escapeRegExp(value: string): string {
560
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
561
+ }
562
+
563
+ function clampNumber(
564
+ value: unknown,
565
+ min: number,
566
+ max: number,
567
+ fallback: number,
568
+ ) {
569
+ const n =
570
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
571
+ return Math.min(Math.max(Math.floor(n), min), max);
572
+ }
573
+
574
+ function trimToolLine(line: string): string {
575
+ return line.length > MAX_CODE_TOOL_LINE_CHARS
576
+ ? `${line.slice(0, MAX_CODE_TOOL_LINE_CHARS)}…`
577
+ : line;
578
+ }
579
+
580
+ function codePathMatchesFilter(relPath: string, filter?: string): boolean {
581
+ const normalizedFilter = filter?.trim().replaceAll("\\", "/").toLowerCase();
582
+ if (!normalizedFilter) return true;
583
+ return codeContextFilterText(relPath)
584
+ .toLowerCase()
585
+ .includes(normalizedFilter);
586
+ }
587
+
588
+ function formatCodeFileTree(paths: string[], limit: number): string {
589
+ const lines: string[] = [];
590
+ const seen = new Set<string>();
591
+ for (const fileId of paths.slice(0, limit)) {
592
+ const ref = getCodeContextFileRef(fileId);
593
+ const rootLabel = ref?.root.name ?? "Code Context";
594
+ const relPath = ref?.relPath ?? fileId;
595
+ const rootKey = `root:${rootLabel}`;
596
+ if (!seen.has(rootKey)) {
597
+ seen.add(rootKey);
598
+ lines.push(`${rootLabel}/`);
599
+ }
600
+ const parts = relPath.split("/");
601
+ for (let i = 0; i < parts.length - 1; i++) {
602
+ const dirPath = `${rootLabel}:${parts.slice(0, i + 1).join("/")}`;
603
+ if (seen.has(dirPath)) continue;
604
+ seen.add(dirPath);
605
+ lines.push(`${" ".repeat(i + 1)}${parts[i]}/`);
606
+ }
607
+ lines.push(`${" ".repeat(Math.max(1, parts.length))}${parts.at(-1)}`);
608
+ }
609
+ return lines.join("\n");
610
+ }
611
+
612
+ async function readCodeTextWithinLimit(
613
+ relPath: string,
614
+ maxBytes = MAX_CODE_TOOL_READ_BYTES,
615
+ ): Promise<{ content: string; truncated: boolean; byteLength: number }> {
616
+ const resolved = resolveCodeContextFilePath(relPath);
617
+ if (!resolved) throw new Error("Access denied");
618
+ const buffer = await fs.readFile(resolved);
619
+ const truncated = buffer.byteLength > maxBytes;
620
+ return {
621
+ content: buffer.slice(0, maxBytes).toString("utf-8"),
622
+ truncated,
623
+ byteLength: buffer.byteLength,
624
+ };
625
+ }
626
+
627
+ async function searchCodeInFiles(
628
+ scopedFiles: string[],
629
+ params: {
630
+ query: string;
631
+ isRegex?: boolean;
632
+ caseSensitive?: boolean;
633
+ pathFilter?: string;
634
+ maxResults?: number;
635
+ contextLines?: number;
636
+ wholeWord?: boolean;
637
+ },
638
+ ) {
639
+ const query = params.query.trim();
640
+ if (!query) return { type: "error", error: "query is required" };
641
+ if (query.length > 500) {
642
+ return { type: "error", error: "query is too long" };
643
+ }
644
+
645
+ const maxResults = clampNumber(
646
+ params.maxResults,
647
+ 1,
648
+ MAX_CODE_TOOL_SEARCH_RESULTS,
649
+ 50,
650
+ );
651
+ const contextLines = clampNumber(
652
+ params.contextLines,
653
+ 0,
654
+ MAX_CODE_TOOL_CONTEXT_LINES,
655
+ 1,
656
+ );
657
+
658
+ let matcher: RegExp;
659
+ try {
660
+ const source = params.isRegex ? query : escapeRegExp(query);
661
+ const wholeWord =
662
+ params.wholeWord && /^[A-Za-z_$][\w$]*$/.test(query)
663
+ ? `\\b${source}\\b`
664
+ : source;
665
+ matcher = new RegExp(wholeWord, params.caseSensitive ? "" : "i");
666
+ } catch (err: any) {
667
+ return { type: "error", error: err?.message || "Invalid regex" };
668
+ }
669
+
670
+ const candidateFiles = scopedFiles.filter((file) =>
671
+ codePathMatchesFilter(file, params.pathFilter),
672
+ );
673
+ const matches: Array<{
674
+ path: string;
675
+ displayPath: string;
676
+ line: number;
677
+ lineText: string;
678
+ before: Array<{ line: number; text: string }>;
679
+ after: Array<{ line: number; text: string }>;
680
+ }> = [];
681
+ const truncatedFiles: string[] = [];
682
+ const unreadableFiles: string[] = [];
683
+
684
+ for (const relPath of candidateFiles) {
685
+ let content: string;
686
+ try {
687
+ const read = await readCodeTextWithinLimit(
688
+ relPath,
689
+ MAX_CODE_TOOL_SEARCH_FILE_BYTES,
690
+ );
691
+ content = read.content;
692
+ if (read.truncated) truncatedFiles.push(relPath);
693
+ } catch {
694
+ unreadableFiles.push(relPath);
695
+ continue;
696
+ }
697
+
698
+ const lines = content.split(/\r?\n/);
699
+ for (let i = 0; i < lines.length; i++) {
700
+ if (!matcher.test(lines[i])) continue;
701
+ matcher.lastIndex = 0;
702
+ const beforeStart = Math.max(0, i - contextLines);
703
+ const afterEnd = Math.min(lines.length, i + contextLines + 1);
704
+ matches.push({
705
+ path: relPath,
706
+ displayPath: codeContextDisplayPath(relPath),
707
+ line: i + 1,
708
+ lineText: trimToolLine(lines[i]),
709
+ before: lines.slice(beforeStart, i).map((text, offset) => ({
710
+ line: beforeStart + offset + 1,
711
+ text: trimToolLine(text),
712
+ })),
713
+ after: lines.slice(i + 1, afterEnd).map((text, offset) => ({
714
+ line: i + offset + 2,
715
+ text: trimToolLine(text),
716
+ })),
717
+ });
718
+ if (matches.length >= maxResults) {
719
+ return {
720
+ query,
721
+ searchedFiles: candidateFiles.length,
722
+ truncated: true,
723
+ truncatedFiles,
724
+ unreadableFiles,
725
+ matches,
726
+ };
727
+ }
728
+ }
729
+ }
730
+
731
+ return {
732
+ query,
733
+ searchedFiles: candidateFiles.length,
734
+ truncated: false,
735
+ truncatedFiles,
736
+ unreadableFiles,
737
+ matches,
738
+ };
739
+ }
740
+
741
+ function classifyConfigFile(relPath: string): string | null {
742
+ const lower = codeContextRelPath(relPath).toLowerCase();
743
+ const base = lower.split("/").at(-1) || lower;
744
+ if (/^\.github\/workflows\/.+\.ya?ml$/.test(lower)) {
745
+ return "github-actions-workflow";
746
+ }
747
+ if (base === "action.yml" || base === "action.yaml") return "github-action";
748
+ if (base === "azure-pipelines.yml" || base === "azure-pipelines.yaml") {
749
+ return "azure-pipelines";
750
+ }
751
+ if (base === ".gitlab-ci.yml" || base === ".gitlab-ci.yaml") {
752
+ return "gitlab-ci";
753
+ }
754
+ if (base === "config.yml" && lower.includes(".circleci/")) {
755
+ return "circleci";
756
+ }
757
+ if (base === "jenkinsfile") return "jenkins";
758
+ if (lower.includes("buildkite") && lower.endsWith(".yml")) {
759
+ return "buildkite";
760
+ }
761
+ if (base === "package.json") return "package-json";
762
+ if (base === "dockerfile" || base.endsWith(".dockerfile")) {
763
+ return "dockerfile";
764
+ }
765
+ if (
766
+ base === "docker-compose.yml" ||
767
+ base === "docker-compose.yaml" ||
768
+ base === "compose.yml" ||
769
+ base === "compose.yaml"
770
+ ) {
771
+ return "docker-compose";
772
+ }
773
+ if (lower.endsWith(".tf") || lower.endsWith(".tfvars")) return "terraform";
774
+ if (lower.includes("terraform/") && lower.endsWith(".hcl")) {
775
+ return "terraform-hcl";
776
+ }
777
+ return null;
778
+ }
779
+
780
+ function summarizeConfigStructure(relPath: string, content: string) {
781
+ const relativePath = codeContextRelPath(relPath);
782
+ const lower = relativePath.toLowerCase();
783
+ if (lower.endsWith(".json")) {
784
+ try {
785
+ const parsed = JSON.parse(content);
786
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
787
+ const record = parsed as Record<string, unknown>;
788
+ return {
789
+ topLevelKeys: Object.keys(record).slice(0, 50),
790
+ scripts:
791
+ record.scripts && typeof record.scripts === "object"
792
+ ? Object.keys(record.scripts as Record<string, unknown>).slice(
793
+ 0,
794
+ 50,
795
+ )
796
+ : undefined,
797
+ };
798
+ }
799
+ } catch {
800
+ return { parseError: "Invalid JSON" };
801
+ }
802
+ }
803
+
804
+ if (/\.ya?ml$/i.test(relativePath)) {
805
+ const topLevelKeys = uniqueStrings(
806
+ content
807
+ .split(/\r?\n/)
808
+ .map((line) => line.match(/^([A-Za-z0-9_.-]+):/)?.[1] || "")
809
+ .filter(Boolean),
810
+ ).slice(0, 50);
811
+ return { topLevelKeys };
812
+ }
813
+
814
+ if (/\.(tf|tfvars|hcl)$/i.test(relativePath)) {
815
+ const blocks = uniqueStrings(
816
+ content
817
+ .split(/\r?\n/)
818
+ .map(
819
+ (line) =>
820
+ line
821
+ .trim()
822
+ .match(
823
+ /^(terraform|provider|resource|module|variable|output|data|locals|backend)\b.*$/,
824
+ )?.[0],
825
+ )
826
+ .filter((line): line is string => Boolean(line)),
827
+ ).slice(0, 60);
828
+ return { blocks };
829
+ }
830
+
831
+ return {};
832
+ }
833
+
834
+ function extractConfigHighlights(content: string) {
835
+ const interesting =
836
+ /\b(terraform\s+(init|plan|apply)|backend-config|azurerm|workflow_call|secrets:\s*inherit|uses:|runs-on:|permissions:|concurrency:|jobs:|steps:|env:|provider\s+"|backend\s+"|resource\s+")/i;
837
+ return content
838
+ .split(/\r?\n/)
839
+ .map((text, index) => ({ line: index + 1, text: trimToolLine(text) }))
840
+ .filter(({ text }) => interesting.test(text))
841
+ .slice(0, 30);
842
+ }
843
+
844
+ function createCodeContextTools(scopedCodeFiles: string[]): ToolSet {
845
+ if (CODE_CONTEXT_ROOTS.length === 0 || scopedCodeFiles.length === 0)
846
+ return {};
847
+
848
+ const files = uniqueStrings(scopedCodeFiles).sort((a, b) =>
849
+ a.localeCompare(b),
850
+ );
851
+ const fileSet = new Set(files);
852
+
853
+ return {
854
+ listCodeFiles: tool({
855
+ description:
856
+ "List code-context files that are available to the model. This is read-only and restricted to files the user selected in Code Context, under configured code roots, after ignore rules are applied.",
857
+ inputSchema: z.object({
858
+ query: z
859
+ .string()
860
+ .optional()
861
+ .describe(
862
+ "Optional case-insensitive path substring to filter files.",
863
+ ),
864
+ folder: z
865
+ .string()
866
+ .optional()
867
+ .describe(
868
+ "Optional folder prefix or path substring to narrow results.",
869
+ ),
870
+ limit: z
871
+ .number()
872
+ .int()
873
+ .min(1)
874
+ .max(MAX_CODE_TOOL_LIST_RESULTS)
875
+ .optional(),
876
+ }),
877
+ execute: async ({ query, folder, limit }) => {
878
+ const cappedLimit = clampNumber(
879
+ limit,
880
+ 1,
881
+ MAX_CODE_TOOL_LIST_RESULTS,
882
+ 120,
883
+ );
884
+ const filtered = files.filter(
885
+ (file) =>
886
+ codePathMatchesFilter(file, query) &&
887
+ codePathMatchesFilter(file, folder),
888
+ );
889
+ const listed = filtered.slice(0, cappedLimit);
890
+ return {
891
+ scope: "selected-code-context-files",
892
+ totalAvailable: files.length,
893
+ totalMatched: filtered.length,
894
+ truncated: filtered.length > listed.length,
895
+ files: listed,
896
+ displayFiles: listed.map(codeContextDisplayPath),
897
+ tree: formatCodeFileTree(listed, cappedLimit),
898
+ };
899
+ },
900
+ }),
901
+ searchCode: tool({
902
+ description:
903
+ "Search text across available code-context files. Use this before opening many files. It cannot see ignored or unselected files.",
904
+ inputSchema: z.object({
905
+ query: z.string().describe("Text or regex to search for."),
906
+ isRegex: z
907
+ .boolean()
908
+ .optional()
909
+ .describe("Treat query as a JavaScript regular expression."),
910
+ caseSensitive: z.boolean().optional(),
911
+ pathFilter: z
912
+ .string()
913
+ .optional()
914
+ .describe(
915
+ "Optional path substring, e.g. .github/workflows or terraform.",
916
+ ),
917
+ maxResults: z
918
+ .number()
919
+ .int()
920
+ .min(1)
921
+ .max(MAX_CODE_TOOL_SEARCH_RESULTS)
922
+ .optional(),
923
+ contextLines: z
924
+ .number()
925
+ .int()
926
+ .min(0)
927
+ .max(MAX_CODE_TOOL_CONTEXT_LINES)
928
+ .optional(),
929
+ }),
930
+ execute: async (params) => searchCodeInFiles(files, params),
931
+ }),
932
+ readCodeFile: tool({
933
+ description:
934
+ "Read one available code-context file by canonical path, optionally with a 1-based line range. Restricted to selected, non-ignored files.",
935
+ inputSchema: z.object({
936
+ path: z
937
+ .string()
938
+ .describe(
939
+ "Canonical path from listCodeFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the first root.",
940
+ ),
941
+ startLine: z.number().int().min(1).optional(),
942
+ endLine: z.number().int().min(1).optional(),
943
+ }),
944
+ execute: async ({ path: requestedPath, startLine, endLine }) => {
945
+ const ref = getCodeContextFileRef(requestedPath || "");
946
+ const fileId = ref?.id;
947
+ if (!ref || !fileId || !fileSet.has(fileId)) {
948
+ return {
949
+ type: "error",
950
+ error:
951
+ "File is not available. Use listCodeFiles first; only selected, non-ignored files can be read.",
952
+ };
953
+ }
954
+
955
+ try {
956
+ const read = await readCodeTextWithinLimit(fileId);
957
+ const lines = read.content.split(/\r?\n/);
958
+ const totalLines = lines.length;
959
+ const first = clampNumber(startLine, 1, totalLines || 1, 1);
960
+ const last = clampNumber(
961
+ endLine,
962
+ first,
963
+ totalLines || first,
964
+ totalLines || first,
965
+ );
966
+ const numbered = lines
967
+ .slice(first - 1, last)
968
+ .map((line, index) => `${first + index}: ${line}`)
969
+ .join("\n");
970
+ return {
971
+ path: fileId,
972
+ displayPath: codeContextDisplayPath(fileId),
973
+ startLine: first,
974
+ endLine: last,
975
+ totalLines,
976
+ byteLength: read.byteLength,
977
+ truncated: read.truncated,
978
+ content: numbered,
979
+ };
980
+ } catch (err: any) {
981
+ return {
982
+ type: "error",
983
+ error: err?.message || "Could not read file",
984
+ };
985
+ }
986
+ },
987
+ }),
988
+ findCodeReferences: tool({
989
+ description:
990
+ "Find textual references to a symbol, function name, workflow name, file name, variable, or config key across available code-context files.",
991
+ inputSchema: z.object({
992
+ symbol: z.string().describe("Exact symbol or phrase to find."),
993
+ pathFilter: z.string().optional(),
994
+ caseSensitive: z.boolean().optional(),
995
+ maxResults: z
996
+ .number()
997
+ .int()
998
+ .min(1)
999
+ .max(MAX_CODE_TOOL_SEARCH_RESULTS)
1000
+ .optional(),
1001
+ contextLines: z
1002
+ .number()
1003
+ .int()
1004
+ .min(0)
1005
+ .max(MAX_CODE_TOOL_CONTEXT_LINES)
1006
+ .optional(),
1007
+ }),
1008
+ execute: async ({
1009
+ symbol,
1010
+ pathFilter,
1011
+ caseSensitive,
1012
+ maxResults,
1013
+ contextLines,
1014
+ }) =>
1015
+ searchCodeInFiles(files, {
1016
+ query: symbol,
1017
+ pathFilter,
1018
+ caseSensitive,
1019
+ maxResults,
1020
+ contextLines,
1021
+ wholeWord: true,
1022
+ }),
1023
+ }),
1024
+ inspectCiConfig: tool({
1025
+ description:
1026
+ "Find and summarize CI/CD, GitHub Actions, Docker, package script, and Terraform config files among the available code-context files.",
1027
+ inputSchema: z.object({
1028
+ pathFilter: z.string().optional(),
1029
+ maxFiles: z.number().int().min(1).max(30).optional(),
1030
+ }),
1031
+ execute: async ({ pathFilter, maxFiles }) => {
1032
+ const limit = clampNumber(maxFiles, 1, 30, 12);
1033
+ const candidates = files
1034
+ .map((file) => ({ file, kind: classifyConfigFile(file) }))
1035
+ .filter(
1036
+ (entry): entry is { file: string; kind: string } =>
1037
+ Boolean(entry.kind) &&
1038
+ codePathMatchesFilter(entry.file, pathFilter),
1039
+ );
1040
+
1041
+ const inspected = [];
1042
+ for (const { file, kind } of candidates.slice(0, limit)) {
1043
+ try {
1044
+ const read = await readCodeTextWithinLimit(file);
1045
+ inspected.push({
1046
+ path: file,
1047
+ displayPath: codeContextDisplayPath(file),
1048
+ kind,
1049
+ truncated: read.truncated,
1050
+ structure: summarizeConfigStructure(file, read.content),
1051
+ highlights: extractConfigHighlights(read.content),
1052
+ });
1053
+ } catch (err: any) {
1054
+ inspected.push({
1055
+ path: file,
1056
+ displayPath: codeContextDisplayPath(file),
1057
+ kind,
1058
+ error: err?.message || "Could not inspect file",
1059
+ });
1060
+ }
1061
+ }
1062
+
1063
+ return {
1064
+ totalConfigFiles: candidates.length,
1065
+ truncated: candidates.length > inspected.length,
1066
+ files: inspected,
1067
+ };
1068
+ },
1069
+ }),
1070
+ runReadOnlyCodeCommand: tool({
1071
+ description:
1072
+ "Run a safe, whitelisted, read-only repository inspection command without a shell. Allowed commands: pwd, find, rg, git-ls-files, git-log, git-blame. Results are restricted to selected code-context files.",
1073
+ inputSchema: z.object({
1074
+ command: z.enum([
1075
+ "pwd",
1076
+ "find",
1077
+ "rg",
1078
+ "git-ls-files",
1079
+ "git-log",
1080
+ "git-blame",
1081
+ ]),
1082
+ query: z
1083
+ .string()
1084
+ .optional()
1085
+ .describe("Required for rg. Optional for find."),
1086
+ path: z
1087
+ .string()
1088
+ .optional()
1089
+ .describe("Required for git-log and git-blame."),
1090
+ pathFilter: z.string().optional(),
1091
+ startLine: z.number().int().min(1).optional(),
1092
+ endLine: z.number().int().min(1).optional(),
1093
+ maxResults: z
1094
+ .number()
1095
+ .int()
1096
+ .min(1)
1097
+ .max(MAX_CODE_TOOL_LIST_RESULTS)
1098
+ .optional(),
1099
+ }),
1100
+ execute: async ({
1101
+ command,
1102
+ query,
1103
+ path: requestedPath,
1104
+ pathFilter,
1105
+ startLine,
1106
+ endLine,
1107
+ maxResults,
1108
+ }) => {
1109
+ const limit = clampNumber(
1110
+ maxResults,
1111
+ 1,
1112
+ MAX_CODE_TOOL_LIST_RESULTS,
1113
+ 100,
1114
+ );
1115
+ if (command === "pwd") {
1116
+ return {
1117
+ command: "pwd",
1118
+ roots: CODE_CONTEXT_ROOTS.map((root) => ({
1119
+ id: root.id,
1120
+ name: root.name,
1121
+ path: root.dir,
1122
+ })),
1123
+ note: "Read-only tools are still restricted to selected, non-ignored code-context files across these roots.",
1124
+ };
1125
+ }
1126
+ if (command === "find") {
1127
+ const filtered = files
1128
+ .filter(
1129
+ (file) =>
1130
+ codePathMatchesFilter(file, query) &&
1131
+ codePathMatchesFilter(file, pathFilter),
1132
+ )
1133
+ .slice(0, limit);
1134
+ return {
1135
+ command: "find",
1136
+ files: filtered,
1137
+ displayFiles: filtered.map(codeContextDisplayPath),
1138
+ truncated: filtered.length >= limit,
1139
+ };
1140
+ }
1141
+ if (command === "rg") {
1142
+ if (!query?.trim())
1143
+ return { type: "error", error: "query is required for rg" };
1144
+ return searchCodeInFiles(files, {
1145
+ query,
1146
+ pathFilter,
1147
+ maxResults: Math.min(limit, MAX_CODE_TOOL_SEARCH_RESULTS),
1148
+ contextLines: 1,
1149
+ });
1150
+ }
1151
+
1152
+ if (command === "git-ls-files") {
1153
+ try {
1154
+ const matchingFiles: string[] = [];
1155
+ const roots = CODE_CONTEXT_ROOTS.filter((root) =>
1156
+ files.some(
1157
+ (file) => getCodeContextFileRef(file)?.root.id === root.id,
1158
+ ),
1159
+ );
1160
+ const errors: string[] = [];
1161
+
1162
+ for (const root of roots) {
1163
+ const result = await runGit(["ls-files", "-z"], {
1164
+ cwd: root.dir,
1165
+ allowFail: true,
1166
+ timeoutMs: 5000,
1167
+ maxBuffer: 1024 * 1024,
1168
+ });
1169
+ if (result.code !== 0) {
1170
+ errors.push(
1171
+ `${root.name}: ${result.stderr.trim() || "git ls-files failed"}`,
1172
+ );
1173
+ continue;
1174
+ }
1175
+ const tracked = new Set(
1176
+ result.stdout.split("\0").filter(Boolean),
1177
+ );
1178
+ for (const file of files) {
1179
+ const ref = getCodeContextFileRef(file);
1180
+ if (ref?.root.id !== root.id) continue;
1181
+ if (
1182
+ tracked.has(ref.relPath) &&
1183
+ codePathMatchesFilter(file, pathFilter)
1184
+ ) {
1185
+ matchingFiles.push(file);
1186
+ }
1187
+ }
1188
+ }
1189
+ const filtered = matchingFiles.slice(0, limit);
1190
+ return {
1191
+ command: "git ls-files",
1192
+ files: filtered,
1193
+ displayFiles: filtered.map(codeContextDisplayPath),
1194
+ errors,
1195
+ truncated: filtered.length >= limit,
1196
+ };
1197
+ } catch (err: any) {
1198
+ return {
1199
+ type: "error",
1200
+ error: err?.message || "git ls-files failed",
1201
+ };
1202
+ }
1203
+ }
1204
+
1205
+ const fileRef = getCodeContextFileRef(requestedPath || "");
1206
+ const fileId = fileRef?.id;
1207
+ if (!fileRef || !fileId || !fileSet.has(fileId)) {
1208
+ return {
1209
+ type: "error",
1210
+ error:
1211
+ "path must be one of the selected, non-ignored code-context files",
1212
+ };
1213
+ }
1214
+
1215
+ if (command === "git-log") {
1216
+ try {
1217
+ const result = await runGit(
1218
+ [
1219
+ "log",
1220
+ "--oneline",
1221
+ "--decorate",
1222
+ `--max-count=${Math.min(limit, 50)}`,
1223
+ "--",
1224
+ fileRef.relPath,
1225
+ ],
1226
+ {
1227
+ cwd: fileRef.root.dir,
1228
+ allowFail: true,
1229
+ timeoutMs: 5000,
1230
+ maxBuffer: 512 * 1024,
1231
+ },
1232
+ );
1233
+ return {
1234
+ command: "git log",
1235
+ path: fileId,
1236
+ displayPath: codeContextDisplayPath(fileId),
1237
+ output:
1238
+ result.stdout.trim() ||
1239
+ result.stderr.trim() ||
1240
+ "No git history found.",
1241
+ };
1242
+ } catch (err: any) {
1243
+ return { type: "error", error: err?.message || "git log failed" };
1244
+ }
1245
+ }
1246
+
1247
+ try {
1248
+ const args = ["blame", "--date=short"];
1249
+ if (startLine || endLine) {
1250
+ const first = Math.max(1, startLine ?? 1);
1251
+ const last = Math.max(first, endLine ?? first);
1252
+ args.push("-L", `${first},${last}`);
1253
+ }
1254
+ args.push("--", fileRef.relPath);
1255
+ const result = await runGit(args, {
1256
+ cwd: fileRef.root.dir,
1257
+ allowFail: true,
1258
+ timeoutMs: 5000,
1259
+ maxBuffer: 512 * 1024,
1260
+ });
1261
+ return {
1262
+ command: "git blame",
1263
+ path: fileId,
1264
+ displayPath: codeContextDisplayPath(fileId),
1265
+ output:
1266
+ result.stdout.trim() ||
1267
+ result.stderr.trim() ||
1268
+ "No git blame output.",
1269
+ };
1270
+ } catch (err: any) {
1271
+ return { type: "error", error: err?.message || "git blame failed" };
1272
+ }
1273
+ },
1274
+ }),
1275
+ };
1276
+ }
1277
+
1278
+ function createGitDiffTools(ctx: GitDiffContextPayload): ToolSet {
1279
+ if (
1280
+ GIT_DIFF_ROOTS.length === 0 ||
1281
+ !Array.isArray(ctx.selectedFiles) ||
1282
+ ctx.selectedFiles.length === 0
1283
+ ) {
1284
+ return {};
1285
+ }
1286
+
1287
+ async function loadScoped() {
1288
+ const scoped = await getScopedGitDiffContext(ctx);
1289
+ if (!scoped) {
1290
+ throw new Error(
1291
+ "No selected git diff files are available for this root/range.",
1292
+ );
1293
+ }
1294
+ return scoped;
1295
+ }
1296
+
1297
+ function formatEntry(entry: ScopedGitDiffEntry) {
1298
+ return {
1299
+ path: entry.id,
1300
+ displayPath: entry.displayPath,
1301
+ relativePath: entry.path,
1302
+ oldPath: entry.oldPath,
1303
+ status: entry.status,
1304
+ additions: entry.additions,
1305
+ deletions: entry.deletions,
1306
+ binary: entry.binary,
1307
+ };
1308
+ }
1309
+
1310
+ return {
1311
+ listGitDiffFiles: tool({
1312
+ description:
1313
+ "List selected git diff files for the configured diff root/range. Restricted to files the user selected in Git Diff context.",
1314
+ inputSchema: z.object({
1315
+ query: z
1316
+ .string()
1317
+ .optional()
1318
+ .describe("Optional case-insensitive path/root/status filter."),
1319
+ status: z
1320
+ .enum(["A", "M", "D", "R", "C", "T", "U", "?"])
1321
+ .optional()
1322
+ .describe("Optional git status filter."),
1323
+ limit: z.number().int().min(1).max(500).optional(),
1324
+ }),
1325
+ execute: async ({ query, status, limit }) => {
1326
+ try {
1327
+ const scoped = await loadScoped();
1328
+ const filter = query?.trim().toLowerCase();
1329
+ const cappedLimit = clampNumber(limit, 1, 500, 200);
1330
+ const matched = scoped.entries.filter((entry) => {
1331
+ if (status && entry.status !== status) return false;
1332
+ if (!filter) return true;
1333
+ return `${entry.id} ${entry.displayPath} ${entry.status}`
1334
+ .toLowerCase()
1335
+ .includes(filter);
1336
+ });
1337
+ return {
1338
+ root: {
1339
+ id: scoped.root.id,
1340
+ name: scoped.root.name,
1341
+ },
1342
+ range: scoped.rangeLabel,
1343
+ totalSelected: scoped.entries.length,
1344
+ totalMatched: matched.length,
1345
+ truncated: matched.length > cappedLimit,
1346
+ files: matched.slice(0, cappedLimit).map(formatEntry),
1347
+ };
1348
+ } catch (err: any) {
1349
+ return { type: "error", error: err?.message || "diff list failed" };
1350
+ }
1351
+ },
1352
+ }),
1353
+ readGitDiffFile: tool({
1354
+ description:
1355
+ "Read a selected git diff file as a patch, before blob, or after blob. Use patch for exact changed hunks; use before/after to compare full file versions.",
1356
+ inputSchema: z.object({
1357
+ path: z
1358
+ .string()
1359
+ .describe(
1360
+ "Canonical path from listGitDiffFiles, e.g. root::src/file.ts. Legacy relative paths resolve against the selected diff root.",
1361
+ ),
1362
+ view: z
1363
+ .enum(["patch", "before", "after"])
1364
+ .optional()
1365
+ .describe("Which diff view to read. Defaults to patch."),
1366
+ startLine: z.number().int().min(1).optional(),
1367
+ endLine: z.number().int().min(1).optional(),
1368
+ }),
1369
+ execute: async ({ path: requestedPath, view, startLine, endLine }) => {
1370
+ try {
1371
+ const scoped = await loadScoped();
1372
+ const entry = findScopedGitDiffEntry(scoped, requestedPath);
1373
+ if (!entry) {
1374
+ return {
1375
+ type: "error",
1376
+ error: "File is not in the selected git diff context.",
1377
+ };
1378
+ }
1379
+ const selectedView = view ?? "patch";
1380
+ const content = await readGitDiffView(scoped, entry, selectedView);
1381
+ const lines = content.split(/\r?\n/);
1382
+ const totalLines = lines.length;
1383
+ const first = clampNumber(startLine, 1, totalLines || 1, 1);
1384
+ const last = clampNumber(
1385
+ endLine,
1386
+ first,
1387
+ totalLines || first,
1388
+ totalLines || first,
1389
+ );
1390
+ const selectedLines = lines
1391
+ .slice(first - 1, last)
1392
+ .map((line, index) => `${first + index}: ${line}`)
1393
+ .join("\n");
1394
+ return {
1395
+ path: entry.id,
1396
+ displayPath: entry.displayPath,
1397
+ view: selectedView,
1398
+ status: entry.status,
1399
+ startLine: first,
1400
+ endLine: last,
1401
+ totalLines,
1402
+ content: selectedLines || "(empty)",
1403
+ };
1404
+ } catch (err: any) {
1405
+ return { type: "error", error: err?.message || "diff read failed" };
1406
+ }
1407
+ },
1408
+ }),
1409
+ searchGitDiff: tool({
1410
+ description:
1411
+ "Search selected git diff patches and/or before/after blobs for exact text or a regular expression. Useful for finding specific changed symbols, config keys, or hunks.",
1412
+ inputSchema: z.object({
1413
+ query: z.string().min(1),
1414
+ view: z
1415
+ .enum(["patch", "before", "after", "all"])
1416
+ .optional()
1417
+ .describe("Which view to search. Defaults to patch."),
1418
+ regex: z.boolean().optional(),
1419
+ caseSensitive: z.boolean().optional(),
1420
+ pathFilter: z.string().optional(),
1421
+ limit: z.number().int().min(1).max(200).optional(),
1422
+ }),
1423
+ execute: async ({
1424
+ query,
1425
+ view,
1426
+ regex,
1427
+ caseSensitive,
1428
+ pathFilter,
1429
+ limit,
1430
+ }) => {
1431
+ try {
1432
+ const scoped = await loadScoped();
1433
+ const cappedLimit = clampNumber(limit, 1, 200, 80);
1434
+ const selectedView = view ?? "patch";
1435
+ const flags = caseSensitive ? "" : "i";
1436
+ const pattern = new RegExp(
1437
+ regex ? query : escapeRegExp(query),
1438
+ flags,
1439
+ );
1440
+ const filter = pathFilter?.trim().toLowerCase();
1441
+ const views =
1442
+ selectedView === "all"
1443
+ ? (["patch", "before", "after"] as const)
1444
+ : ([selectedView] as const);
1445
+ const matches: Array<{
1446
+ path: string;
1447
+ displayPath: string;
1448
+ view: "patch" | "before" | "after";
1449
+ line: number;
1450
+ lineText: string;
1451
+ }> = [];
1452
+
1453
+ for (const entry of scoped.entries) {
1454
+ if (
1455
+ filter &&
1456
+ !`${entry.id} ${entry.displayPath}`.toLowerCase().includes(filter)
1457
+ ) {
1458
+ continue;
1459
+ }
1460
+ for (const currentView of views) {
1461
+ if (
1462
+ currentView === "before" &&
1463
+ (entry.status === "A" || entry.status === "?")
1464
+ ) {
1465
+ continue;
1466
+ }
1467
+ if (currentView === "after" && entry.status === "D") {
1468
+ continue;
1469
+ }
1470
+ const content = await readGitDiffView(scoped, entry, currentView);
1471
+ const lines = content.split(/\r?\n/);
1472
+ for (let i = 0; i < lines.length; i += 1) {
1473
+ pattern.lastIndex = 0;
1474
+ if (!pattern.test(lines[i])) continue;
1475
+ matches.push({
1476
+ path: entry.id,
1477
+ displayPath: entry.displayPath,
1478
+ view: currentView,
1479
+ line: i + 1,
1480
+ lineText: trimToolLine(lines[i]),
1481
+ });
1482
+ if (matches.length >= cappedLimit) {
1483
+ return {
1484
+ query,
1485
+ view: selectedView,
1486
+ totalMatches: matches.length,
1487
+ truncated: true,
1488
+ matches,
1489
+ };
1490
+ }
1491
+ }
1492
+ }
1493
+ }
1494
+
1495
+ return {
1496
+ query,
1497
+ view: selectedView,
1498
+ totalMatches: matches.length,
1499
+ truncated: false,
1500
+ matches,
1501
+ };
1502
+ } catch (err: any) {
1503
+ return { type: "error", error: err?.message || "diff search failed" };
1504
+ }
1505
+ },
1506
+ }),
1507
+ summarizeGitDiff: tool({
1508
+ description:
1509
+ "Summarize the selected git diff context with totals by status and additions/deletions. Use this before deeper reads when multiple files are selected.",
1510
+ inputSchema: z.object({
1511
+ includeFiles: z.boolean().optional(),
1512
+ }),
1513
+ execute: async ({ includeFiles }) => {
1514
+ try {
1515
+ const scoped = await loadScoped();
1516
+ const byStatus: Record<string, number> = {};
1517
+ let additions = 0;
1518
+ let deletions = 0;
1519
+ let binaryFiles = 0;
1520
+ for (const entry of scoped.entries) {
1521
+ byStatus[entry.status] = (byStatus[entry.status] ?? 0) + 1;
1522
+ additions += entry.additions;
1523
+ deletions += entry.deletions;
1524
+ if (entry.binary) binaryFiles += 1;
1525
+ }
1526
+ return {
1527
+ root: {
1528
+ id: scoped.root.id,
1529
+ name: scoped.root.name,
1530
+ },
1531
+ range: scoped.rangeLabel,
1532
+ filesChanged: scoped.entries.length,
1533
+ additions,
1534
+ deletions,
1535
+ binaryFiles,
1536
+ byStatus,
1537
+ ...(includeFiles
1538
+ ? { files: scoped.entries.map(formatEntry).slice(0, 200) }
1539
+ : {}),
1540
+ };
1541
+ } catch (err: any) {
1542
+ return {
1543
+ type: "error",
1544
+ error: err?.message || "diff summary failed",
1545
+ };
1546
+ }
1547
+ },
1548
+ }),
1549
+ };
1550
+ }
347
1551
 
348
1552
  // ─── AI Providers (Vercel AI SDK) ────────────────────────
349
1553
  // Set the provider + model in .env. Supports: openai, google, anthropic
@@ -998,6 +2202,7 @@ app.patch("/api/questions/:id", async (req, res) => {
998
2202
  delete q.gitDiffContext;
999
2203
  } else if (gdc && typeof gdc === "object") {
1000
2204
  q.gitDiffContext = {
2205
+ rootId: String(gdc.rootId || ""),
1001
2206
  baseRef: String(gdc.baseRef || ""),
1002
2207
  headRef: String(gdc.headRef || ""),
1003
2208
  mode:
@@ -1984,16 +3189,19 @@ app.post("/api/chat", async (req, res) => {
1984
3189
  }
1985
3190
  }
1986
3191
 
3192
+ const scopedCodeContextFiles =
3193
+ await getScopedCodeContextFiles(codeContextFiles);
3194
+
1987
3195
  // Code-context files from the project directory
1988
- if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
1989
- for (const filePath of codeContextFiles as string[]) {
1990
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
1991
- const resolved = path.resolve(fullPath);
1992
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
3196
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
3197
+ for (const filePath of scopedCodeContextFiles) {
3198
+ const ref = getCodeContextFileRef(filePath);
3199
+ if (!ref) continue;
1993
3200
  fileRegistry.set(
1994
3201
  `code:${filePath}`,
1995
- makeCodeReferenceFileEntry(`[code] ${filePath}`, () =>
1996
- fs.readFile(resolved, "utf-8"),
3202
+ makeCodeReferenceFileEntry(
3203
+ `[code:${ref.root.name}] ${ref.relPath}`,
3204
+ () => fs.readFile(ref.absolutePath, "utf-8"),
1997
3205
  ),
1998
3206
  );
1999
3207
  }
@@ -2029,16 +3237,27 @@ For image files, readFile returns visual image data so you can inspect what is v
2029
3237
 
2030
3238
  if (codeFilePaths.length > 0) {
2031
3239
  system += `
3240
+ --- Code Context Exploration Tools ---
3241
+ You also have read-only code tools for the selected Code Context files:
3242
+ - listCodeFiles: see the selected, non-ignored file tree.
3243
+ - searchCode: search exact text or regex across selected files.
3244
+ - readCodeFile: open a selected file, optionally by line range.
3245
+ - findCodeReferences: trace textual usages of a symbol, config key, workflow, or file name.
3246
+ - inspectCiConfig: summarize CI/CD, package, Docker, and Terraform config among selected files.
3247
+ - runReadOnlyCodeCommand: safe whitelisted equivalents for pwd/find/rg/git-ls-files/git-log/git-blame.
3248
+
3249
+ These tools cannot access files outside the configured code-context roots, files hidden by ignore rules, or files the user did not select. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.
3250
+
2032
3251
  --- Linking Code Files in Your Response ---
2033
3252
  When you mention or reference one of the **[code]** files above in your response text, format it as a clickable link so the user can open it directly:
2034
3253
 
2035
- [DisplayText](coderef://relative/path/to/file)
3254
+ [DisplayText](coderef://root::relative/path/to/file)
2036
3255
 
2037
3256
  Examples:
2038
- [EmployeesController](coderef://src/Controllers/EmployeesController.cs)
2039
- [SettleDeferredPaymentCaseRequest](coderef://src/Requests/SettleDeferredPaymentCaseRequest.cs)
3257
+ [EmployeesController](coderef://backend::src/Controllers/EmployeesController.cs)
3258
+ [SettleDeferredPaymentCaseRequest](coderef://api::src/Requests/SettleDeferredPaymentCaseRequest.cs)
2040
3259
 
2041
- Use this for class names, method names, or any mention of a specific file from the code context. The display text should be the class, file, or concept name — not the raw path.
3260
+ Use this for class names, method names, or any mention of a specific file from the code context. The href must use the exact code file id shown above without the "code:" prefix. The display text should be the class, file, or concept name — not the raw path.
2042
3261
  Only use coderef:// for [code] files. Do not use it for uploaded documents.`;
2043
3262
  }
2044
3263
 
@@ -2160,6 +3379,8 @@ Examples (illustrative only — use real ids and names from the list above):
2160
3379
  readFile: createReadFileTool(fileRegistry),
2161
3380
  }
2162
3381
  : {}),
3382
+ ...createCodeContextTools(scopedCodeContextFiles),
3383
+ ...createGitDiffTools(gitDiffContext || {}),
2163
3384
  },
2164
3385
  stopWhen: stepCountIs(selectedResponseProfile.maxSteps),
2165
3386
  });
@@ -2359,6 +3580,24 @@ function hasIgnoredExtension(fileNameLower: string): boolean {
2359
3580
  return false;
2360
3581
  }
2361
3582
 
3583
+ function isCodeContextPathAllowed(relPath: string): boolean {
3584
+ const normalized = normalizeCodeContextRelPath(relPath);
3585
+ if (!normalized) return false;
3586
+
3587
+ const parts = normalized.split("/");
3588
+ for (let i = 0; i < parts.length - 1; i++) {
3589
+ const prefix = parts.slice(0, i + 1).join("/");
3590
+ if (IGNORE_DIRS.has(normalizeName(parts[i])) || shouldIgnorePath(prefix)) {
3591
+ return false;
3592
+ }
3593
+ }
3594
+
3595
+ const fileName = parts.at(-1)?.toLowerCase() ?? "";
3596
+ if (!fileName || IGNORE_FILES.has(fileName)) return false;
3597
+ if (shouldIgnorePath(normalized)) return false;
3598
+ return !hasIgnoredExtension(fileName);
3599
+ }
3600
+
2362
3601
  async function walkDir(dir: string, prefix = ""): Promise<string[]> {
2363
3602
  const entries = await fs.readdir(dir, { withFileTypes: true });
2364
3603
  const files: string[] = [];
@@ -2380,30 +3619,34 @@ async function walkDir(dir: string, prefix = ""): Promise<string[]> {
2380
3619
  }
2381
3620
 
2382
3621
  app.get("/api/code-context/tree", async (_req, res) => {
2383
- if (!CODE_CONTEXT_DIR) return res.json([]);
3622
+ if (CODE_CONTEXT_ROOTS.length === 0) return res.json({ roots: [] });
2384
3623
  try {
2385
- const files = await walkDir(CODE_CONTEXT_DIR);
2386
- res.json(files);
3624
+ res.json(await getCodeContextRootsTree());
2387
3625
  } catch {
2388
- res.json([]);
3626
+ res.json({ roots: [] });
2389
3627
  }
2390
3628
  });
2391
3629
 
2392
3630
  app.get("/api/code-context/file", async (req, res) => {
2393
- if (!CODE_CONTEXT_DIR)
2394
- return res.status(400).json({ error: "No code context directory" });
3631
+ if (CODE_CONTEXT_ROOTS.length === 0)
3632
+ return res.status(400).json({ error: "No code context roots" });
2395
3633
  const filePath = req.query.path as string;
2396
3634
  if (!filePath) return res.status(400).json({ error: "Path required" });
2397
3635
 
2398
- const fullPath = path.join(CODE_CONTEXT_DIR, filePath);
2399
- const resolved = path.resolve(fullPath);
2400
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) {
3636
+ const ref = getCodeContextFileRef(filePath);
3637
+ if (!ref) {
2401
3638
  return res.status(403).json({ error: "Access denied" });
2402
3639
  }
2403
3640
 
2404
3641
  try {
2405
- const content = await fs.readFile(resolved, "utf-8");
2406
- res.json({ path: filePath, content });
3642
+ const content = await fs.readFile(ref.absolutePath, "utf-8");
3643
+ res.json({
3644
+ path: ref.id,
3645
+ rootId: ref.root.id,
3646
+ rootName: ref.root.name,
3647
+ relativePath: ref.relPath,
3648
+ content,
3649
+ });
2407
3650
  } catch {
2408
3651
  res.status(404).json({ error: "File not found" });
2409
3652
  }
@@ -2416,7 +3659,108 @@ app.get("/api/code-context/file", async (req, res) => {
2416
3659
  // or before/after blobs are pulled lazily via the readFile tool using ids of
2417
3660
  // the form gitdiff:patch:<path> / gitdiff:before:<path> / gitdiff:after:<path>.
2418
3661
 
2419
- const GIT_DIFF_DIR = process.env.GIT_DIFF_DIR || CODE_CONTEXT_DIR;
3662
+ function resolveGitDiffRoots(): CodeContextRootConfig[] {
3663
+ const specs = [
3664
+ ...(process.env.GIT_DIFF_DIR
3665
+ ? [
3666
+ process.env.GIT_DIFF_NAME
3667
+ ? `${process.env.GIT_DIFF_NAME}=${process.env.GIT_DIFF_DIR}`
3668
+ : process.env.GIT_DIFF_DIR,
3669
+ ]
3670
+ : []),
3671
+ ...splitCodeContextRootEnv(process.env.GIT_DIFF_DIRS),
3672
+ ];
3673
+
3674
+ if (specs.length === 0) {
3675
+ return CODE_CONTEXT_ROOTS.map((root) => ({ ...root }));
3676
+ }
3677
+
3678
+ const seenDirs = new Set<string>();
3679
+ const usedNames = new Map<string, number>();
3680
+ const usedIds = new Set<string>();
3681
+ const roots: CodeContextRootConfig[] = [];
3682
+
3683
+ for (const spec of specs) {
3684
+ const parsed = parseCodeContextRootSpec(spec);
3685
+ if (!parsed) continue;
3686
+ const dir = path.resolve(parsed.dir);
3687
+ if (seenDirs.has(dir)) continue;
3688
+ seenDirs.add(dir);
3689
+ const fallbackName = path.basename(dir) || "Git Diff";
3690
+ const name = uniqueCodeContextRootName(
3691
+ parsed.name || fallbackName,
3692
+ usedNames,
3693
+ );
3694
+ const id = uniqueCodeContextRootId(slugForCodeContextRoot(name), usedIds);
3695
+ roots.push({ id, name, dir });
3696
+ }
3697
+
3698
+ return roots;
3699
+ }
3700
+
3701
+ const GIT_DIFF_ROOTS = resolveGitDiffRoots();
3702
+ const GIT_DIFF_ROOT_BY_ID = new Map(
3703
+ GIT_DIFF_ROOTS.map((root) => [root.id, root]),
3704
+ );
3705
+ const GIT_DIFF_DIR = GIT_DIFF_ROOTS[0]?.dir ?? "";
3706
+
3707
+ function getGitDiffPrimaryRoot(): CodeContextRootConfig | null {
3708
+ return GIT_DIFF_ROOTS[0] ?? null;
3709
+ }
3710
+
3711
+ function getGitDiffRoot(rootId?: string | null): CodeContextRootConfig | null {
3712
+ const normalized = rootId?.trim();
3713
+ if (normalized) return GIT_DIFF_ROOT_BY_ID.get(normalized) ?? null;
3714
+ return getGitDiffPrimaryRoot();
3715
+ }
3716
+
3717
+ function makeGitDiffFileId(rootId: string, relPath: string): string {
3718
+ return `${rootId}${CODE_CONTEXT_ROOT_SEPARATOR}${relPath}`;
3719
+ }
3720
+
3721
+ interface GitDiffFileRef {
3722
+ id: string;
3723
+ root: CodeContextRootConfig;
3724
+ relPath: string;
3725
+ }
3726
+
3727
+ function getGitDiffFileRef(
3728
+ fileIdOrPath: string,
3729
+ fallbackRootId?: string,
3730
+ ): GitDiffFileRef | null {
3731
+ if (GIT_DIFF_ROOTS.length === 0) return null;
3732
+ const raw = fileIdOrPath
3733
+ .replace(/^gitdiff:(patch|before|after):/, "")
3734
+ .replaceAll("\\", "/")
3735
+ .trim();
3736
+ if (!raw || raw.includes("\0")) return null;
3737
+
3738
+ const sepIdx = raw.indexOf(CODE_CONTEXT_ROOT_SEPARATOR);
3739
+ const root =
3740
+ sepIdx > 0
3741
+ ? GIT_DIFF_ROOT_BY_ID.get(raw.slice(0, sepIdx))
3742
+ : getGitDiffRoot(fallbackRootId);
3743
+ const relRaw =
3744
+ sepIdx > 0 ? raw.slice(sepIdx + CODE_CONTEXT_ROOT_SEPARATOR.length) : raw;
3745
+ if (!root) return null;
3746
+
3747
+ const normalized = normalizeCodeContextRelPath(relRaw);
3748
+ if (!normalized || !isCodeContextPathAllowed(normalized)) return null;
3749
+
3750
+ return {
3751
+ id: makeGitDiffFileId(root.id, normalized),
3752
+ root,
3753
+ relPath: normalized,
3754
+ };
3755
+ }
3756
+
3757
+ function getGitDiffRootSummaries() {
3758
+ return GIT_DIFF_ROOTS.map((root) => ({
3759
+ id: root.id,
3760
+ name: root.name,
3761
+ path: root.dir,
3762
+ }));
3763
+ }
2420
3764
 
2421
3765
  // Hard caps so a runaway repo can never blow up the response or the model's context.
2422
3766
  const MAX_DIFF_FILES = 500;
@@ -2437,6 +3781,21 @@ interface GitDiffFileEntry {
2437
3781
  binary: boolean;
2438
3782
  }
2439
3783
 
3784
+ interface ScopedGitDiffEntry extends GitDiffFileEntry {
3785
+ id: string;
3786
+ displayPath: string;
3787
+ }
3788
+
3789
+ interface ScopedGitDiffContext {
3790
+ root: CodeContextRootConfig;
3791
+ base: string;
3792
+ head: string;
3793
+ headLabel: string;
3794
+ mode: GitDiffMode;
3795
+ rangeLabel: string;
3796
+ entries: ScopedGitDiffEntry[];
3797
+ }
3798
+
2440
3799
  function runGit(
2441
3800
  args: string[],
2442
3801
  opts: {
@@ -2523,13 +3882,16 @@ function isValidRef(ref: string): boolean {
2523
3882
  return REF_PATTERN.test(ref);
2524
3883
  }
2525
3884
 
2526
- async function resolveRef(ref: string): Promise<string | null> {
3885
+ async function resolveRef(
3886
+ root: CodeContextRootConfig,
3887
+ ref: string,
3888
+ ): Promise<string | null> {
2527
3889
  if (!isValidRef(ref)) return null;
2528
3890
  try {
2529
3891
  const { stdout } = await runGit(
2530
3892
  ["rev-parse", "--verify", `${ref}^{commit}`],
2531
3893
  {
2532
- cwd: GIT_DIFF_DIR,
3894
+ cwd: root.dir,
2533
3895
  },
2534
3896
  );
2535
3897
  return stdout.trim() || null;
@@ -2560,6 +3922,7 @@ function buildDiffRange(
2560
3922
  }
2561
3923
 
2562
3924
  async function getChangedFiles(
3925
+ root: CodeContextRootConfig,
2563
3926
  base: string,
2564
3927
  head: string,
2565
3928
  mode: GitDiffMode,
@@ -2569,10 +3932,10 @@ async function getChangedFiles(
2569
3932
 
2570
3933
  const nameStatus = await runGit(
2571
3934
  [...baseArgs, "--name-status", "-z", ...range],
2572
- { cwd: GIT_DIFF_DIR },
3935
+ { cwd: root.dir },
2573
3936
  );
2574
3937
  const numStat = await runGit([...baseArgs, "--numstat", "-z", ...range], {
2575
- cwd: GIT_DIFF_DIR,
3938
+ cwd: root.dir,
2576
3939
  });
2577
3940
 
2578
3941
  // numstat with -z uses NUL between records AND between additions/deletions/path,
@@ -2630,7 +3993,7 @@ async function getChangedFiles(
2630
3993
  try {
2631
3994
  const untracked = await runGit(
2632
3995
  ["ls-files", "--others", "--exclude-standard", "-z"],
2633
- { cwd: GIT_DIFF_DIR },
3996
+ { cwd: root.dir },
2634
3997
  );
2635
3998
  for (const filePath of untracked.stdout
2636
3999
  .split("\0")
@@ -2648,10 +4011,17 @@ async function getChangedFiles(
2648
4011
  }
2649
4012
  }
2650
4013
 
2651
- return entries.slice(0, MAX_DIFF_FILES);
4014
+ return entries
4015
+ .filter(
4016
+ (entry) =>
4017
+ isCodeContextPathAllowed(entry.path) &&
4018
+ (!entry.oldPath || isCodeContextPathAllowed(entry.oldPath)),
4019
+ )
4020
+ .slice(0, MAX_DIFF_FILES);
2652
4021
  }
2653
4022
 
2654
4023
  async function getDiffPatch(
4024
+ root: CodeContextRootConfig,
2655
4025
  base: string,
2656
4026
  head: string,
2657
4027
  mode: GitDiffMode,
@@ -2668,7 +4038,7 @@ async function getDiffPatch(
2668
4038
  "--",
2669
4039
  filePath,
2670
4040
  ],
2671
- { cwd: GIT_DIFF_DIR, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
4041
+ { cwd: root.dir, maxBuffer: MAX_DIFF_FILE_BYTES * 4 },
2672
4042
  );
2673
4043
  if (stdout.length > MAX_DIFF_FILE_BYTES) {
2674
4044
  return (
@@ -2679,10 +4049,16 @@ async function getDiffPatch(
2679
4049
  return stdout;
2680
4050
  }
2681
4051
 
2682
- async function getFileAtRef(ref: string, filePath: string): Promise<string> {
4052
+ async function getFileAtRef(
4053
+ root: CodeContextRootConfig,
4054
+ ref: string,
4055
+ filePath: string,
4056
+ ): Promise<string> {
2683
4057
  if (ref === WORKING_TREE_SENTINEL) {
2684
- const resolved = path.resolve(path.join(GIT_DIFF_DIR, filePath));
2685
- if (!resolved.startsWith(path.resolve(GIT_DIFF_DIR))) {
4058
+ const rootDir = path.resolve(root.dir);
4059
+ const resolved = path.resolve(path.join(rootDir, filePath));
4060
+ const relativeToRoot = path.relative(rootDir, resolved);
4061
+ if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot)) {
2686
4062
  throw new Error("Access denied");
2687
4063
  }
2688
4064
  const buf = await fs.readFile(resolved);
@@ -2695,7 +4071,7 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
2695
4071
  return buf.toString("utf-8");
2696
4072
  }
2697
4073
  const { stdout } = await runGit(["show", `${ref}:${filePath}`], {
2698
- cwd: GIT_DIFF_DIR,
4074
+ cwd: root.dir,
2699
4075
  maxBuffer: MAX_DIFF_FILE_BYTES * 4,
2700
4076
  });
2701
4077
  if (stdout.length > MAX_DIFF_FILE_BYTES) {
@@ -2707,92 +4083,224 @@ async function getFileAtRef(ref: string, filePath: string): Promise<string> {
2707
4083
  return stdout;
2708
4084
  }
2709
4085
 
2710
- app.get("/api/code-context/git/branches", async (_req, res) => {
2711
- if (!GIT_DIFF_DIR) return res.json({ enabled: false, branches: [] });
4086
+ async function getScopedGitDiffContext(
4087
+ ctx: GitDiffContextPayload,
4088
+ ): Promise<ScopedGitDiffContext | null> {
4089
+ if (GIT_DIFF_ROOTS.length === 0) return null;
4090
+ const selectedRaw = Array.isArray(ctx.selectedFiles)
4091
+ ? ctx.selectedFiles.filter((p): p is string => typeof p === "string")
4092
+ : [];
4093
+ const inferredRootId =
4094
+ ctx.rootId ||
4095
+ selectedRaw
4096
+ .map((p) => getGitDiffFileRef(p)?.root.id)
4097
+ .find((id): id is string => Boolean(id));
4098
+ const root = getGitDiffRoot(inferredRootId || null);
4099
+ if (!root) return null;
4100
+
4101
+ const base = (ctx.baseRef || "").trim();
4102
+ if (!base || !isValidRef(base)) return null;
4103
+ const mode = parseDiffMode(ctx.mode);
4104
+ const headRaw = (ctx.headRef || "").trim();
4105
+ const head = mode === "working-tree" ? WORKING_TREE_SENTINEL : headRaw;
4106
+ if (mode !== "working-tree" && (!head || !isValidRef(head))) return null;
4107
+
4108
+ const selectedRefs = selectedRaw
4109
+ .map((p) => getGitDiffFileRef(p, root.id))
4110
+ .filter((p): p is GitDiffFileRef => Boolean(p))
4111
+ .filter((p) => p.root.id === root.id);
4112
+ if (selectedRefs.length === 0) return null;
4113
+
4114
+ let changedFiles: GitDiffFileEntry[];
2712
4115
  try {
2713
- // Refresh remote-tracking refs first. This is intentionally non-interactive
2714
- // and time-capped so private/offline repos fall back to already-local refs.
2715
- await runGit(["fetch", "--all", "--prune", "--quiet"], {
2716
- cwd: GIT_DIFF_DIR,
2717
- allowFail: true,
2718
- timeoutMs: 8000,
2719
- });
4116
+ changedFiles = await getChangedFiles(root, base, head, mode);
4117
+ } catch {
4118
+ return null;
4119
+ }
4120
+ const byPath = new Map(changedFiles.map((f) => [f.path, f]));
4121
+ const selectedPaths = uniqueStrings(selectedRefs.map((ref) => ref.relPath));
4122
+ const entries = selectedPaths
4123
+ .map((relPath) => byPath.get(relPath))
4124
+ .filter((entry): entry is GitDiffFileEntry => Boolean(entry))
4125
+ .map((entry) => ({
4126
+ ...entry,
4127
+ id: makeGitDiffFileId(root.id, entry.path),
4128
+ displayPath: `${root.name}/${entry.path}`,
4129
+ }));
4130
+ if (entries.length === 0) return null;
2720
4131
 
2721
- const [head, branches, tags, remotes] = await Promise.all([
2722
- runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: GIT_DIFF_DIR }),
2723
- runGit(
2724
- [
2725
- "for-each-ref",
2726
- "--sort=-committerdate",
2727
- `--count=${MAX_BRANCHES}`,
2728
- "--format=%(refname:short)",
2729
- "refs/heads/",
2730
- "refs/remotes/",
2731
- ],
2732
- { cwd: GIT_DIFF_DIR },
2733
- ),
2734
- runGit(
2735
- [
2736
- "for-each-ref",
2737
- "--sort=-creatordate",
2738
- "--count=50",
2739
- "--format=%(refname:short)",
2740
- "refs/tags/",
2741
- ],
2742
- { cwd: GIT_DIFF_DIR, allowFail: true },
2743
- ),
2744
- runGit(["remote"], { cwd: GIT_DIFF_DIR, allowFail: true }),
2745
- ]);
2746
- const remoteNames = remotes.stdout
2747
- .split("\n")
2748
- .map((s) => s.trim())
2749
- .filter(Boolean);
2750
- const lsRemoteResults = await Promise.all(
2751
- remoteNames.slice(0, 5).map((remote) =>
2752
- runGit(["ls-remote", "--heads", remote], {
2753
- cwd: GIT_DIFF_DIR,
2754
- allowFail: true,
2755
- timeoutMs: 5000,
2756
- maxBuffer: 1024 * 1024,
2757
- }).then((result) =>
2758
- result.stdout
2759
- .split("\n")
2760
- .map((line) => line.trim().split("\t")[1] || "")
2761
- .filter((ref) => ref.startsWith("refs/heads/"))
2762
- .map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
2763
- ),
4132
+ const headLabel = mode === "working-tree" ? "working tree" : head;
4133
+ const rangeLabel =
4134
+ mode === "working-tree"
4135
+ ? `${base} → working tree`
4136
+ : `${base} ${mode === "two-dot" ? ".." : "..."} ${headLabel}`;
4137
+
4138
+ return {
4139
+ root,
4140
+ base,
4141
+ head,
4142
+ headLabel,
4143
+ mode,
4144
+ rangeLabel,
4145
+ entries,
4146
+ };
4147
+ }
4148
+
4149
+ function findScopedGitDiffEntry(
4150
+ scoped: ScopedGitDiffContext,
4151
+ fileIdOrPath: string,
4152
+ ): ScopedGitDiffEntry | null {
4153
+ const ref = getGitDiffFileRef(fileIdOrPath, scoped.root.id);
4154
+ if (!ref || ref.root.id !== scoped.root.id) return null;
4155
+ return (
4156
+ scoped.entries.find(
4157
+ (entry) => entry.id === ref.id || entry.path === ref.relPath,
4158
+ ) ?? null
4159
+ );
4160
+ }
4161
+
4162
+ async function readGitDiffView(
4163
+ scoped: ScopedGitDiffContext,
4164
+ entry: ScopedGitDiffEntry,
4165
+ view: "patch" | "before" | "after",
4166
+ ): Promise<string> {
4167
+ if (view === "before") {
4168
+ if (entry.status === "A" || entry.status === "?") return "";
4169
+ return getFileAtRef(scoped.root, scoped.base, entry.oldPath ?? entry.path);
4170
+ }
4171
+ if (view === "after") {
4172
+ if (entry.status === "D") return "";
4173
+ const ref =
4174
+ scoped.mode === "working-tree" ? WORKING_TREE_SENTINEL : scoped.head;
4175
+ return getFileAtRef(scoped.root, ref, entry.path);
4176
+ }
4177
+ return getDiffPatch(
4178
+ scoped.root,
4179
+ scoped.base,
4180
+ scoped.head,
4181
+ scoped.mode,
4182
+ entry.path,
4183
+ );
4184
+ }
4185
+
4186
+ async function loadGitBranchesForRoot(root: CodeContextRootConfig) {
4187
+ // Refresh remote-tracking refs first. This is intentionally non-interactive
4188
+ // and time-capped so private/offline repos fall back to already-local refs.
4189
+ await runGit(["fetch", "--all", "--prune", "--quiet"], {
4190
+ cwd: root.dir,
4191
+ allowFail: true,
4192
+ timeoutMs: 8000,
4193
+ });
4194
+
4195
+ const [head, branches, tags, remotes] = await Promise.all([
4196
+ runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root.dir }),
4197
+ runGit(
4198
+ [
4199
+ "for-each-ref",
4200
+ "--sort=-committerdate",
4201
+ `--count=${MAX_BRANCHES}`,
4202
+ "--format=%(refname:short)",
4203
+ "refs/heads/",
4204
+ "refs/remotes/",
4205
+ ],
4206
+ { cwd: root.dir },
4207
+ ),
4208
+ runGit(
4209
+ [
4210
+ "for-each-ref",
4211
+ "--sort=-creatordate",
4212
+ "--count=50",
4213
+ "--format=%(refname:short)",
4214
+ "refs/tags/",
4215
+ ],
4216
+ { cwd: root.dir, allowFail: true },
4217
+ ),
4218
+ runGit(["remote"], { cwd: root.dir, allowFail: true }),
4219
+ ]);
4220
+ const remoteNames = remotes.stdout
4221
+ .split("\n")
4222
+ .map((s) => s.trim())
4223
+ .filter(Boolean);
4224
+ const lsRemoteResults = await Promise.all(
4225
+ remoteNames.slice(0, 5).map((remote) =>
4226
+ runGit(["ls-remote", "--heads", remote], {
4227
+ cwd: root.dir,
4228
+ allowFail: true,
4229
+ timeoutMs: 5000,
4230
+ maxBuffer: 1024 * 1024,
4231
+ }).then((result) =>
4232
+ result.stdout
4233
+ .split("\n")
4234
+ .map((line) => line.trim().split("\t")[1] || "")
4235
+ .filter((ref) => ref.startsWith("refs/heads/"))
4236
+ .map((ref) => `${remote}/${ref.replace("refs/heads/", "")}`),
2764
4237
  ),
2765
- );
2766
- const branchList = uniqueStrings([
2767
- ...branches.stdout
2768
- .split("\n")
2769
- .map((s) => s.trim())
2770
- .filter(Boolean),
2771
- ...lsRemoteResults.flat(),
2772
- ]).filter((b) => !b.endsWith("/HEAD"));
2773
- const tagList = tags.stdout
4238
+ ),
4239
+ );
4240
+ const branchList = uniqueStrings([
4241
+ ...branches.stdout
2774
4242
  .split("\n")
2775
4243
  .map((s) => s.trim())
2776
- .filter(Boolean);
2777
- const currentHead = head.stdout.trim();
4244
+ .filter(Boolean),
4245
+ ...lsRemoteResults.flat(),
4246
+ ]).filter((b) => !b.endsWith("/HEAD"));
4247
+ const tagList = tags.stdout
4248
+ .split("\n")
4249
+ .map((s) => s.trim())
4250
+ .filter(Boolean);
4251
+ const currentHead = head.stdout.trim();
4252
+
4253
+ return {
4254
+ enabled: true,
4255
+ rootId: root.id,
4256
+ rootName: root.name,
4257
+ head: currentHead,
4258
+ defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
4259
+ branches: branchList,
4260
+ tags: tagList,
4261
+ };
4262
+ }
4263
+
4264
+ app.get("/api/code-context/git/branches", async (req, res) => {
4265
+ const roots = getGitDiffRootSummaries();
4266
+ if (GIT_DIFF_ROOTS.length === 0) {
4267
+ return res.json({ enabled: false, branches: [], roots });
4268
+ }
4269
+ const root = getGitDiffRoot(
4270
+ String(req.query.root || req.query.rootId || "").trim(),
4271
+ );
4272
+ if (!root) {
4273
+ return res.status(404).json({
4274
+ enabled: false,
4275
+ branches: [],
4276
+ roots,
4277
+ error: "Git diff root not found",
4278
+ });
4279
+ }
4280
+ try {
2778
4281
  res.json({
2779
- enabled: true,
2780
- head: currentHead,
2781
- defaultBranch: chooseDefaultBaseBranch(currentHead, branchList),
2782
- branches: branchList,
2783
- tags: tagList,
4282
+ ...(await loadGitBranchesForRoot(root)),
4283
+ roots,
2784
4284
  });
2785
4285
  } catch (err: any) {
2786
4286
  res.status(500).json({
2787
4287
  enabled: false,
4288
+ rootId: root.id,
4289
+ rootName: root.name,
2788
4290
  branches: [],
4291
+ roots,
2789
4292
  error: err?.message || "git not available",
2790
4293
  });
2791
4294
  }
2792
4295
  });
2793
4296
 
2794
4297
  app.get("/api/code-context/git/diff-tree", async (req, res) => {
2795
- if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
4298
+ if (GIT_DIFF_ROOTS.length === 0)
4299
+ return res.status(400).json({ error: "No git diff roots" });
4300
+ const root = getGitDiffRoot(
4301
+ String(req.query.root || req.query.rootId || "").trim(),
4302
+ );
4303
+ if (!root) return res.status(404).json({ error: "Git diff root not found" });
2796
4304
 
2797
4305
  const base = String(req.query.base || "").trim();
2798
4306
  const headRaw = String(req.query.head || "").trim();
@@ -2801,7 +4309,7 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
2801
4309
  if (!isValidRef(base))
2802
4310
  return res.status(400).json({ error: "Invalid base ref" });
2803
4311
 
2804
- const baseSha = await resolveRef(base);
4312
+ const baseSha = await resolveRef(root, base);
2805
4313
  if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
2806
4314
 
2807
4315
  let head = headRaw;
@@ -2811,13 +4319,15 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
2811
4319
  } else {
2812
4320
  if (!isValidRef(head))
2813
4321
  return res.status(400).json({ error: "Invalid head ref" });
2814
- headSha = await resolveRef(head);
4322
+ headSha = await resolveRef(root, head);
2815
4323
  if (!headSha) return res.status(404).json({ error: "Head ref not found" });
2816
4324
  }
2817
4325
 
2818
4326
  try {
2819
- const files = await getChangedFiles(base, head, mode);
4327
+ const files = await getChangedFiles(root, base, head, mode);
2820
4328
  res.json({
4329
+ rootId: root.id,
4330
+ rootName: root.name,
2821
4331
  base,
2822
4332
  baseSha,
2823
4333
  head: mode === "working-tree" ? WORKING_TREE_SENTINEL : head,
@@ -2832,20 +4342,27 @@ app.get("/api/code-context/git/diff-tree", async (req, res) => {
2832
4342
  });
2833
4343
 
2834
4344
  app.get("/api/code-context/git/diff-file", async (req, res) => {
2835
- if (!GIT_DIFF_DIR) return res.status(400).json({ error: "No git directory" });
4345
+ if (GIT_DIFF_ROOTS.length === 0)
4346
+ return res.status(400).json({ error: "No git diff roots" });
2836
4347
 
2837
4348
  const base = String(req.query.base || "").trim();
2838
4349
  const headRaw = String(req.query.head || "").trim();
2839
4350
  const mode = parseDiffMode(req.query.mode);
2840
4351
  const filePath = String(req.query.path || "").trim();
2841
4352
  const view = (req.query.view as string) || "patch";
4353
+ const fileRef = getGitDiffFileRef(
4354
+ filePath,
4355
+ String(req.query.root || req.query.rootId || "").trim(),
4356
+ );
2842
4357
 
2843
4358
  if (!isValidRef(base))
2844
4359
  return res.status(400).json({ error: "Invalid base ref" });
2845
- if (!filePath || filePath.includes("\0"))
2846
- return res.status(400).json({ error: "Invalid path" });
4360
+ if (!fileRef) return res.status(400).json({ error: "Invalid path" });
2847
4361
 
2848
- const baseSha = await resolveRef(base);
4362
+ const { root } = fileRef;
4363
+ const relPath = fileRef.relPath;
4364
+
4365
+ const baseSha = await resolveRef(root, base);
2849
4366
  if (!baseSha) return res.status(404).json({ error: "Base ref not found" });
2850
4367
 
2851
4368
  let head = headRaw;
@@ -2854,15 +4371,15 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
2854
4371
  } else {
2855
4372
  if (!isValidRef(head))
2856
4373
  return res.status(400).json({ error: "Invalid head ref" });
2857
- const headSha = await resolveRef(head);
4374
+ const headSha = await resolveRef(root, head);
2858
4375
  if (!headSha) return res.status(404).json({ error: "Head ref not found" });
2859
4376
  }
2860
4377
 
2861
4378
  // Verify the path is actually part of the diff so callers cannot read arbitrary repo files.
2862
4379
  let entry: GitDiffFileEntry | undefined;
2863
4380
  try {
2864
- const files = await getChangedFiles(base, head, mode);
2865
- entry = files.find((f) => f.path === filePath || f.oldPath === filePath);
4381
+ const files = await getChangedFiles(root, base, head, mode);
4382
+ entry = files.find((f) => f.path === relPath || f.oldPath === relPath);
2866
4383
  } catch (err: any) {
2867
4384
  return res.status(500).json({ error: err?.message || "git diff failed" });
2868
4385
  }
@@ -2871,23 +4388,53 @@ app.get("/api/code-context/git/diff-file", async (req, res) => {
2871
4388
  try {
2872
4389
  if (view === "before") {
2873
4390
  if (entry.status === "A" || entry.status === "?") {
2874
- return res.json({ path: filePath, view, content: "" });
4391
+ return res.json({
4392
+ path: fileRef.id,
4393
+ rootId: root.id,
4394
+ rootName: root.name,
4395
+ view,
4396
+ content: "",
4397
+ });
2875
4398
  }
2876
- const refPath = entry.oldPath ?? filePath;
2877
- const content = await getFileAtRef(base, refPath);
2878
- return res.json({ path: filePath, view, content });
4399
+ const refPath = entry.oldPath ?? relPath;
4400
+ const content = await getFileAtRef(root, base, refPath);
4401
+ return res.json({
4402
+ path: fileRef.id,
4403
+ rootId: root.id,
4404
+ rootName: root.name,
4405
+ view,
4406
+ content,
4407
+ });
2879
4408
  }
2880
4409
  if (view === "after") {
2881
4410
  if (entry.status === "D") {
2882
- return res.json({ path: filePath, view, content: "" });
4411
+ return res.json({
4412
+ path: fileRef.id,
4413
+ rootId: root.id,
4414
+ rootName: root.name,
4415
+ view,
4416
+ content: "",
4417
+ });
2883
4418
  }
2884
4419
  const ref = mode === "working-tree" ? WORKING_TREE_SENTINEL : head;
2885
- const content = await getFileAtRef(ref, filePath);
2886
- return res.json({ path: filePath, view, content });
4420
+ const content = await getFileAtRef(root, ref, relPath);
4421
+ return res.json({
4422
+ path: fileRef.id,
4423
+ rootId: root.id,
4424
+ rootName: root.name,
4425
+ view,
4426
+ content,
4427
+ });
2887
4428
  }
2888
4429
  // default: patch
2889
- const patch = await getDiffPatch(base, head, mode, filePath);
2890
- return res.json({ path: filePath, view: "patch", content: patch });
4430
+ const patch = await getDiffPatch(root, base, head, mode, relPath);
4431
+ return res.json({
4432
+ path: fileRef.id,
4433
+ rootId: root.id,
4434
+ rootName: root.name,
4435
+ view: "patch",
4436
+ content: patch,
4437
+ });
2891
4438
  } catch (err: any) {
2892
4439
  res.status(500).json({ error: err?.message || "git read failed" });
2893
4440
  }
@@ -2971,15 +4518,18 @@ app.post("/api/code-line-ask", async (req, res) => {
2971
4518
  }
2972
4519
  }
2973
4520
 
2974
- if (codeContextFiles?.length && CODE_CONTEXT_DIR) {
2975
- for (const fp of codeContextFiles as string[]) {
2976
- const fullPath = path.join(CODE_CONTEXT_DIR, fp);
2977
- const resolved = path.resolve(fullPath);
2978
- if (!resolved.startsWith(path.resolve(CODE_CONTEXT_DIR))) continue;
4521
+ const scopedCodeContextFiles =
4522
+ await getScopedCodeContextFiles(codeContextFiles);
4523
+
4524
+ if (scopedCodeContextFiles.length && CODE_CONTEXT_ROOTS.length > 0) {
4525
+ for (const fp of scopedCodeContextFiles) {
4526
+ const ref = getCodeContextFileRef(fp);
4527
+ if (!ref) continue;
2979
4528
  fileRegistry.set(
2980
4529
  `code:${fp}`,
2981
- makeCodeReferenceFileEntry(`[code] ${fp}`, () =>
2982
- fs.readFile(resolved, "utf-8"),
4530
+ makeCodeReferenceFileEntry(
4531
+ `[code:${ref.root.name}] ${ref.relPath}`,
4532
+ () => fs.readFile(ref.absolutePath, "utf-8"),
2983
4533
  ),
2984
4534
  );
2985
4535
  }
@@ -3020,7 +4570,7 @@ app.post("/api/code-line-ask", async (req, res) => {
3020
4570
  }
3021
4571
 
3022
4572
  if (codeFilePaths.length > 0) {
3023
- system += `\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link:\n [DisplayText](coderef://relative/path/to/file)\nOnly use coderef:// for [code] files.`;
4573
+ system += `\n--- Code Context Exploration Tools ---\nYou also have read-only tools to list, search, read line ranges, trace textual references, inspect CI/CD config, and run whitelisted read-only inspection commands. These tools are restricted to selected, non-ignored files under configured code-context roots. File IDs are root-qualified like "root::relative/path". If something is missing, ask the user to select more code context or adjust ignore settings.\n\n--- Linking Code Files in Your Response ---\nWhen you mention a [code] file, format it as a clickable link using the exact code file id without the "code:" prefix:\n [DisplayText](coderef://root::relative/path/to/file)\nOnly use coderef:// for [code] files.`;
3024
4574
  }
3025
4575
  }
3026
4576
 
@@ -3054,9 +4604,13 @@ app.post("/api/code-line-ask", async (req, res) => {
3054
4604
  system,
3055
4605
  prompt: `File: ${filePath || "unknown"}\n\nHighlighted code:\n\`\`\`\n${selectedCode}\n\`\`\`\n\nQuestion: ${prompt.trim()}`,
3056
4606
  tools:
3057
- fileRegistry.size > 0
4607
+ fileRegistry.size > 0 || scopedCodeContextFiles.length > 0
3058
4608
  ? {
3059
- readFile: createReadFileTool(fileRegistry),
4609
+ ...(fileRegistry.size > 0
4610
+ ? { readFile: createReadFileTool(fileRegistry) }
4611
+ : {}),
4612
+ ...createCodeContextTools(scopedCodeContextFiles),
4613
+ ...createGitDiffTools(gitDiffContext || {}),
3060
4614
  }
3061
4615
  : undefined,
3062
4616
  stopWhen: stepCountIs(4),