getgloss 0.10.0 → 0.12.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.
@@ -10,7 +10,7 @@ import path from "path";
10
10
  // package.json
11
11
  var package_default = {
12
12
  name: "getgloss",
13
- version: "0.10.0",
13
+ version: "0.12.0",
14
14
  description: "Local browser-based diff review for coding-agent loops.",
15
15
  type: "module",
16
16
  packageManager: "pnpm@10.33.2",
@@ -53,6 +53,7 @@ var package_default = {
53
53
  react: "19.2.6",
54
54
  "react-dom": "19.2.6",
55
55
  shiki: "4.1.0",
56
+ "simple-icons": "16.23.0",
56
57
  ulid: "3.0.2",
57
58
  zustand: "5.0.13"
58
59
  },
@@ -200,8 +201,44 @@ var DIFF_LINE_TYPES = ["context", "add", "delete"];
200
201
  var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
201
202
  var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
202
203
  var DIFF_CONTEXT_MAX_LINES = 500;
204
+ var SOURCE_PEEK_MAX_BYTES = 35e4;
203
205
  var REVIEW_SCOPE_MODES = ["all", "single", "range"];
204
206
  var RESOLUTION_STATUSES = ["partial", "resolved"];
207
+ var OPEN_FILE_SCOPES = ["review", "repo"];
208
+ var OPEN_FILE_TARGETS = [
209
+ "default",
210
+ "vscode",
211
+ "vscode-insiders",
212
+ "vscodium",
213
+ "cursor",
214
+ "sublime",
215
+ "zed",
216
+ "windsurf",
217
+ "webstorm",
218
+ "intellij",
219
+ "pycharm",
220
+ "goland",
221
+ "phpstorm",
222
+ "rubymine",
223
+ "clion",
224
+ "datagrip",
225
+ "android-studio",
226
+ "fleet",
227
+ "neovide",
228
+ "macvim",
229
+ "emacs",
230
+ "lapce",
231
+ "textmate",
232
+ "bbedit",
233
+ "coteditor",
234
+ "nova",
235
+ "textedit",
236
+ "xcode",
237
+ "terminal",
238
+ "iterm2",
239
+ "ghostty",
240
+ "folder"
241
+ ];
205
242
 
206
243
  // src/shared/validation.ts
207
244
  function parseJson(raw, guard, label) {
@@ -221,7 +258,10 @@ function isClearReviewsRequest(value) {
221
258
  return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
222
259
  }
223
260
  function isOpenFileRequest(value) {
224
- return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId);
261
+ return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId) && isOptional(value.scope, isOpenFileScope) && isOptional(value.target, isOpenFileTarget);
262
+ }
263
+ function isFileContentRequest(value) {
264
+ return isRecord(value) && isString(value.filePath) && isOptionalString(value.turnId) && isOptional(value.scope, isOpenFileScope);
225
265
  }
226
266
  function isCommitRangeDiffRequest(value) {
227
267
  return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
@@ -229,6 +269,9 @@ function isCommitRangeDiffRequest(value) {
229
269
  function isDiffContextRequest(value) {
230
270
  return isRecord(value) && isString(value.filePath) && isNullableString(value.oldPath) && isOptionalString(value.turnId) && isDiffContextSource(value.source) && isPositiveInteger(value.oldStart) && isPositiveInteger(value.newStart) && isPositiveInteger(value.lineCount);
231
271
  }
272
+ function isSourcePeekRequest(value) {
273
+ return isRecord(value) && isString(value.filePath) && isNullableString(value.oldPath) && isOptionalString(value.turnId) && isDiffContextSource(value.source) && isOneOf(value.side, SIDES) && isPositiveInteger(value.line) && isNonNegativeInteger(value.column) && isIdentifier(value.symbol);
274
+ }
232
275
  function isSubmitReviewRequest(value) {
233
276
  return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
234
277
  }
@@ -332,6 +375,12 @@ function isReviewStatus(value) {
332
375
  function isResolutionStatus(value) {
333
376
  return isOneOf(value, RESOLUTION_STATUSES);
334
377
  }
378
+ function isOpenFileScope(value) {
379
+ return isOneOf(value, OPEN_FILE_SCOPES);
380
+ }
381
+ function isOpenFileTarget(value) {
382
+ return isOneOf(value, OPEN_FILE_TARGETS);
383
+ }
335
384
  function isRecord(value) {
336
385
  return typeof value === "object" && value !== null && !Array.isArray(value);
337
386
  }
@@ -356,6 +405,12 @@ function isNumber(value) {
356
405
  function isPositiveInteger(value) {
357
406
  return isNumber(value) && Number.isInteger(value) && value > 0;
358
407
  }
408
+ function isNonNegativeInteger(value) {
409
+ return isNumber(value) && Number.isInteger(value) && value >= 0;
410
+ }
411
+ function isIdentifier(value) {
412
+ return isString(value) && /^[A-Za-z_$][\w$]*$/.test(value);
413
+ }
359
414
  function isOptionalNumber(value) {
360
415
  return value === void 0 || isNumber(value);
361
416
  }
@@ -514,8 +569,8 @@ function createIdleScheduler(options) {
514
569
  }
515
570
 
516
571
  // src/server/index.ts
517
- import { readFile as readFile5, realpath, stat } from "fs/promises";
518
- import path7 from "path";
572
+ import { readFile as readFile6, realpath, stat } from "fs/promises";
573
+ import path9 from "path";
519
574
  import { fileURLToPath } from "url";
520
575
  import { Hono } from "hono";
521
576
  import { streamSSE } from "hono/streaming";
@@ -797,19 +852,706 @@ function isResolvableReviewStatus(status) {
797
852
  }
798
853
 
799
854
  // src/server/local-open.ts
855
+ import { execFile } from "child_process";
856
+ import { access } from "fs/promises";
857
+ import { homedir as homedir2 } from "os";
858
+ import path6 from "path";
859
+ import { promisify } from "util";
800
860
  import open from "open";
801
- async function openLocalPath(filePath) {
802
- await open(filePath, { wait: false });
861
+ var execFileAsync = promisify(execFile);
862
+ var applicationRoots = [
863
+ "/Applications",
864
+ path6.join(homedir2(), "Applications"),
865
+ "/System/Applications",
866
+ "/System/Applications/Utilities"
867
+ ];
868
+ var openFileTargetDescriptors = [
869
+ { appName: "Visual Studio Code", label: "VS Code", target: "vscode" },
870
+ {
871
+ appName: ["Visual Studio Code - Insiders", "Code - Insiders"],
872
+ label: "VS Code Insiders",
873
+ target: "vscode-insiders"
874
+ },
875
+ { appName: "VSCodium", label: "VSCodium", target: "vscodium" },
876
+ { appName: "Cursor", label: "Cursor", target: "cursor" },
877
+ { appName: "Sublime Text", label: "Sublime Text", target: "sublime" },
878
+ { appName: "Zed", label: "Zed", target: "zed" },
879
+ { appName: "Windsurf", label: "Windsurf", target: "windsurf" },
880
+ { appName: "WebStorm", label: "WebStorm", target: "webstorm" },
881
+ {
882
+ appName: ["IntelliJ IDEA", "IntelliJ IDEA Ultimate", "IntelliJ IDEA CE"],
883
+ label: "IntelliJ IDEA",
884
+ target: "intellij"
885
+ },
886
+ { appName: ["PyCharm", "PyCharm CE"], label: "PyCharm", target: "pycharm" },
887
+ { appName: "GoLand", label: "GoLand", target: "goland" },
888
+ { appName: "PhpStorm", label: "PhpStorm", target: "phpstorm" },
889
+ { appName: "RubyMine", label: "RubyMine", target: "rubymine" },
890
+ { appName: "CLion", label: "CLion", target: "clion" },
891
+ { appName: "DataGrip", label: "DataGrip", target: "datagrip" },
892
+ { appName: "Android Studio", label: "Android Studio", target: "android-studio" },
893
+ { appName: "Fleet", label: "Fleet", target: "fleet" },
894
+ { appName: "Neovide", label: "Neovide", target: "neovide" },
895
+ { appName: "MacVim", label: "MacVim", target: "macvim" },
896
+ { appName: "Emacs", label: "Emacs", target: "emacs" },
897
+ { appName: "Lapce", label: "Lapce", target: "lapce" },
898
+ { appName: "TextMate", label: "TextMate", target: "textmate" },
899
+ { appName: "BBEdit", label: "BBEdit", target: "bbedit" },
900
+ { appName: "CotEditor", label: "CotEditor", target: "coteditor" },
901
+ { appName: "Nova", label: "Nova", target: "nova" },
902
+ { appName: "TextEdit", label: "TextEdit", target: "textedit" },
903
+ { appName: "Terminal", label: "Terminal", opensFolder: true, target: "terminal" },
904
+ { appName: ["iTerm", "iTerm2"], label: "iTerm2", opensFolder: true, target: "iterm2" },
905
+ { appName: "Ghostty", label: "Ghostty", opensFolder: true, target: "ghostty" },
906
+ { appName: "Xcode", label: "Xcode", target: "xcode" },
907
+ { label: "Default app", target: "default" },
908
+ { label: "Open in folder", opensFolder: true, target: "folder" }
909
+ ];
910
+ var openFileTargetDescriptorByTarget = new Map(
911
+ openFileTargetDescriptors.map((descriptor) => [descriptor.target, descriptor])
912
+ );
913
+ async function availableOpenFileTargets() {
914
+ const availability = await Promise.all(
915
+ openFileTargetDescriptors.map(
916
+ async (descriptor) => descriptor.appName && !await resolveAppPath(descriptor.appName) ? null : descriptor
917
+ )
918
+ );
919
+ return availability.filter((descriptor) => descriptor !== null).map(({ label, target }) => ({ label, target }));
920
+ }
921
+ async function openLocalPath(filePath, target = "default") {
922
+ const descriptor = openFileTargetDescriptorByTarget.get(target);
923
+ const openPath = descriptor?.opensFolder ? path6.dirname(filePath) : filePath;
924
+ const appName = descriptor?.appName;
925
+ if (!appName) {
926
+ await open(openPath, { wait: false });
927
+ return;
928
+ }
929
+ const appPath = await resolveAppPath(appName);
930
+ if (appPath) {
931
+ await execFileAsync("open", ["-a", appPath, openPath]);
932
+ return;
933
+ }
934
+ await open(openPath, { app: { name: appName }, wait: false });
935
+ }
936
+ async function resolveAppPath(appName) {
937
+ if (process.platform !== "darwin") {
938
+ return null;
939
+ }
940
+ const appNames = Array.isArray(appName) ? appName : [appName];
941
+ for (const name of appNames) {
942
+ for (const root of applicationRoots) {
943
+ const appPath = path6.join(root, `${name}.app`);
944
+ try {
945
+ await access(appPath);
946
+ return appPath;
947
+ } catch {
948
+ }
949
+ }
950
+ }
951
+ return null;
952
+ }
953
+
954
+ // src/server/source-peek.ts
955
+ import { readFile as readFile3 } from "fs/promises";
956
+ import path7 from "path";
957
+ import { execa as execa2 } from "execa";
958
+ var MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"];
959
+ var PATH_CONFIG_FILES = ["tsconfig.json", "jsconfig.json"];
960
+ var IGNORED_SEARCH_PATHS = ["node_modules", "dist", "build", "coverage", ".git"];
961
+ var IDENTIFIER_PATTERN = "[A-Za-z_$][\\w$]*";
962
+ var SOURCE_PEEK_CONTEXT_LINES = 520;
963
+ var SOURCE_PEEK_TARGET_CONTEXT_LINES = 240;
964
+ async function resolveSourcePeek({
965
+ column,
966
+ line,
967
+ repoRoot,
968
+ sourceFilePath,
969
+ sourceRef,
970
+ symbol
971
+ }) {
972
+ const sourceContent = await readRepoText(repoRoot, sourceRef, sourceFilePath);
973
+ const sourceLines = splitFileLines2(sourceContent);
974
+ const reference = referenceTokenAt(sourceLines[line - 1] ?? "", column, symbol);
975
+ const imports = parseImportBindings(sourceContent);
976
+ const aliasConfig = await loadPathAliasConfig(repoRoot);
977
+ const importedCandidates = candidateImportsForReference(imports, reference);
978
+ for (const candidate of importedCandidates) {
979
+ const resolved = await resolveImportedTarget({
980
+ aliasConfig,
981
+ importedName: candidate.targetSymbol,
982
+ matchReason: "import",
983
+ moduleSpecifier: candidate.moduleSpecifier,
984
+ repoRoot,
985
+ sourceFilePath,
986
+ sourceRef,
987
+ symbol
988
+ });
989
+ if (resolved) {
990
+ return resolved;
991
+ }
992
+ }
993
+ const sameFileMatch = findDefinition(sourceContent, symbol);
994
+ if (sameFileMatch) {
995
+ return responseForMatch({
996
+ column: sameFileMatch.column,
997
+ content: sourceContent,
998
+ filePath: sourceFilePath,
999
+ line: sameFileMatch.line,
1000
+ matchReason: "same-file",
1001
+ symbol,
1002
+ targetSymbol: symbol
1003
+ });
1004
+ }
1005
+ const searched = await searchRepoDefinition(repoRoot, sourceRef, symbol);
1006
+ if (searched) {
1007
+ return responseForMatch({
1008
+ ...searched,
1009
+ matchReason: "repo-search",
1010
+ symbol,
1011
+ targetSymbol: symbol
1012
+ });
1013
+ }
1014
+ throw new Error(`No definition found for ${symbol}`);
1015
+ }
1016
+ async function resolveImportedTarget({
1017
+ aliasConfig,
1018
+ importedName,
1019
+ matchReason,
1020
+ moduleSpecifier,
1021
+ repoRoot,
1022
+ sourceFilePath,
1023
+ sourceRef,
1024
+ symbol
1025
+ }) {
1026
+ const moduleFile = await firstExistingModuleFile(
1027
+ repoRoot,
1028
+ sourceRef,
1029
+ moduleCandidates(repoRoot, aliasConfig, sourceFilePath, moduleSpecifier)
1030
+ );
1031
+ if (!moduleFile) {
1032
+ return null;
1033
+ }
1034
+ const targetMatch = findDefinition(moduleFile.content, importedName);
1035
+ if (targetMatch) {
1036
+ return responseForMatch({
1037
+ column: targetMatch.column,
1038
+ content: moduleFile.content,
1039
+ filePath: moduleFile.filePath,
1040
+ line: targetMatch.line,
1041
+ matchReason,
1042
+ symbol,
1043
+ targetSymbol: importedName
1044
+ });
1045
+ }
1046
+ const reExported = await resolveReExport({
1047
+ aliasConfig,
1048
+ content: moduleFile.content,
1049
+ depth: 0,
1050
+ repoRoot,
1051
+ sourceFilePath: moduleFile.filePath,
1052
+ sourceRef,
1053
+ symbol,
1054
+ targetSymbol: importedName
1055
+ });
1056
+ if (reExported) {
1057
+ return reExported;
1058
+ }
1059
+ if (importedName === "default") {
1060
+ const fallbackLine = firstMeaningfulLine(moduleFile.content);
1061
+ return responseForMatch({
1062
+ column: 0,
1063
+ content: moduleFile.content,
1064
+ filePath: moduleFile.filePath,
1065
+ line: fallbackLine,
1066
+ matchReason: "module",
1067
+ symbol,
1068
+ targetSymbol: importedName
1069
+ });
1070
+ }
1071
+ return null;
1072
+ }
1073
+ async function resolveReExport({
1074
+ aliasConfig,
1075
+ content,
1076
+ depth,
1077
+ repoRoot,
1078
+ sourceFilePath,
1079
+ sourceRef,
1080
+ symbol,
1081
+ targetSymbol
1082
+ }) {
1083
+ if (depth > 3) {
1084
+ return null;
1085
+ }
1086
+ for (const binding of parseReExportBindings(content)) {
1087
+ if (binding.exportedName !== "*" && binding.exportedName !== targetSymbol) {
1088
+ continue;
1089
+ }
1090
+ const nextTarget = binding.importedName === "*" ? targetSymbol : binding.importedName;
1091
+ const moduleFile = await firstExistingModuleFile(
1092
+ repoRoot,
1093
+ sourceRef,
1094
+ moduleCandidates(repoRoot, aliasConfig, sourceFilePath, binding.moduleSpecifier)
1095
+ );
1096
+ if (!moduleFile) {
1097
+ continue;
1098
+ }
1099
+ const match = findDefinition(moduleFile.content, nextTarget);
1100
+ if (match) {
1101
+ return responseForMatch({
1102
+ column: match.column,
1103
+ content: moduleFile.content,
1104
+ filePath: moduleFile.filePath,
1105
+ line: match.line,
1106
+ matchReason: "import",
1107
+ symbol,
1108
+ targetSymbol: nextTarget
1109
+ });
1110
+ }
1111
+ const nested = await resolveReExport({
1112
+ aliasConfig,
1113
+ content: moduleFile.content,
1114
+ depth: depth + 1,
1115
+ repoRoot,
1116
+ sourceFilePath: moduleFile.filePath,
1117
+ sourceRef,
1118
+ symbol,
1119
+ targetSymbol: nextTarget
1120
+ });
1121
+ if (nested) {
1122
+ return nested;
1123
+ }
1124
+ }
1125
+ return null;
1126
+ }
1127
+ async function searchRepoDefinition(repoRoot, sourceRef, symbol) {
1128
+ const grepPattern = definitionGrepPattern(symbol);
1129
+ const args = ["grep", "-n", "-I", "-E", grepPattern];
1130
+ if (sourceRef) {
1131
+ args.push(sourceRef);
1132
+ }
1133
+ args.push("--", ".");
1134
+ for (const ignoredPath of IGNORED_SEARCH_PATHS) {
1135
+ args.push(`:!${ignoredPath}`);
1136
+ }
1137
+ let output = "";
1138
+ try {
1139
+ output = (await execa2("git", args, { cwd: repoRoot })).stdout;
1140
+ } catch (error) {
1141
+ if (isExitCode(error, 1)) {
1142
+ return null;
1143
+ }
1144
+ throw error;
1145
+ }
1146
+ for (const rawLine of output.split("\n")) {
1147
+ const parsed = parseGrepLine(rawLine, sourceRef);
1148
+ if (!parsed) {
1149
+ continue;
1150
+ }
1151
+ const content = await readRepoText(repoRoot, sourceRef, parsed.filePath).catch(() => null);
1152
+ if (!content) {
1153
+ continue;
1154
+ }
1155
+ const match = findDefinition(content, symbol);
1156
+ if (!match) {
1157
+ continue;
1158
+ }
1159
+ return {
1160
+ column: match.column,
1161
+ content,
1162
+ filePath: parsed.filePath,
1163
+ line: match.line
1164
+ };
1165
+ }
1166
+ return null;
1167
+ }
1168
+ async function readRepoText(repoRoot, ref, filePath) {
1169
+ if (ref === null) {
1170
+ return readFile3(path7.resolve(repoRoot, filePath), "utf8");
1171
+ }
1172
+ return (await execa2("git", ["show", `${ref}:${filePath}`], { cwd: repoRoot })).stdout;
1173
+ }
1174
+ function responseForMatch({
1175
+ column,
1176
+ content,
1177
+ filePath,
1178
+ line,
1179
+ matchReason,
1180
+ symbol,
1181
+ targetSymbol
1182
+ }) {
1183
+ const limited = limitContentAroundLine(content, line);
1184
+ return {
1185
+ symbol,
1186
+ targetSymbol,
1187
+ filePath,
1188
+ startLine: limited.startLine,
1189
+ line,
1190
+ column,
1191
+ language: languageForPath(filePath),
1192
+ content: limited.content,
1193
+ truncated: limited.truncated,
1194
+ matchReason
1195
+ };
1196
+ }
1197
+ function limitContentAroundLine(content, line) {
1198
+ if (Buffer.byteLength(content, "utf8") <= SOURCE_PEEK_MAX_BYTES) {
1199
+ return { content, startLine: 1, truncated: false };
1200
+ }
1201
+ const lines = splitFileLines2(content);
1202
+ const targetIndex = Math.max(0, line - 1);
1203
+ const preferredStartIndex = Math.max(0, targetIndex - SOURCE_PEEK_TARGET_CONTEXT_LINES);
1204
+ const preferredEndIndex = Math.min(lines.length, preferredStartIndex + SOURCE_PEEK_CONTEXT_LINES);
1205
+ let startIndex = preferredStartIndex;
1206
+ let endIndex = preferredEndIndex;
1207
+ let limitedContent = lines.slice(startIndex, endIndex).join("\n");
1208
+ while (Buffer.byteLength(limitedContent, "utf8") > SOURCE_PEEK_MAX_BYTES && endIndex - startIndex > 1) {
1209
+ if (targetIndex - startIndex > endIndex - targetIndex - 1) {
1210
+ startIndex += 1;
1211
+ } else {
1212
+ endIndex -= 1;
1213
+ }
1214
+ limitedContent = lines.slice(startIndex, endIndex).join("\n");
1215
+ }
1216
+ if (Buffer.byteLength(limitedContent, "utf8") > SOURCE_PEEK_MAX_BYTES) {
1217
+ limitedContent = truncateUtf8(limitedContent, SOURCE_PEEK_MAX_BYTES);
1218
+ }
1219
+ return {
1220
+ content: limitedContent,
1221
+ startLine: startIndex + 1,
1222
+ truncated: true
1223
+ };
1224
+ }
1225
+ function truncateUtf8(value, maxBytes) {
1226
+ const buffer = Buffer.from(value, "utf8");
1227
+ if (buffer.byteLength <= maxBytes) {
1228
+ return value;
1229
+ }
1230
+ return buffer.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, "");
1231
+ }
1232
+ function candidateImportsForReference(imports, reference) {
1233
+ if (reference.namespace) {
1234
+ return imports.filter(
1235
+ (binding) => binding.kind === "namespace" && binding.localName === reference.namespace
1236
+ ).map((binding) => ({
1237
+ moduleSpecifier: binding.moduleSpecifier,
1238
+ targetSymbol: reference.symbol
1239
+ }));
1240
+ }
1241
+ return imports.filter((binding) => binding.localName === reference.symbol).map((binding) => ({
1242
+ moduleSpecifier: binding.moduleSpecifier,
1243
+ targetSymbol: binding.importedName
1244
+ }));
1245
+ }
1246
+ function referenceTokenAt(line, column, symbol) {
1247
+ let start = Math.min(Math.max(column, 0), line.length);
1248
+ if (line.slice(start, start + symbol.length) !== symbol) {
1249
+ const beforeOrAt = line.lastIndexOf(symbol, start);
1250
+ start = beforeOrAt >= 0 ? beforeOrAt : line.indexOf(symbol);
1251
+ }
1252
+ if (start < 0) {
1253
+ return { namespace: null, symbol };
1254
+ }
1255
+ const namespaceMatch = line.slice(0, start).match(/([A-Za-z_$][\w$]*)\s*\.\s*$/);
1256
+ return {
1257
+ namespace: namespaceMatch?.[1] ?? null,
1258
+ symbol
1259
+ };
1260
+ }
1261
+ function parseImportBindings(content) {
1262
+ const bindings = [];
1263
+ const importRegex = /^\s*import\s+(?:type\s+)?([^;]*?)\s+from\s+['"]([^'"]+)['"];?/gm;
1264
+ let match = importRegex.exec(content);
1265
+ while (match !== null) {
1266
+ const body = match[1]?.trim() ?? "";
1267
+ const moduleSpecifier = match[2] ?? "";
1268
+ for (const part of splitImportParts(body)) {
1269
+ if (part.startsWith("{") && part.endsWith("}")) {
1270
+ bindings.push(
1271
+ ...parseNamedBindings(part).map((binding) => ({
1272
+ ...binding,
1273
+ kind: "named",
1274
+ moduleSpecifier
1275
+ }))
1276
+ );
1277
+ continue;
1278
+ }
1279
+ const namespaceMatch = part.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)$/);
1280
+ if (namespaceMatch?.[1]) {
1281
+ bindings.push({
1282
+ importedName: "*",
1283
+ kind: "namespace",
1284
+ localName: namespaceMatch[1],
1285
+ moduleSpecifier
1286
+ });
1287
+ continue;
1288
+ }
1289
+ if (new RegExp(`^${IDENTIFIER_PATTERN}$`).test(part)) {
1290
+ bindings.push({
1291
+ importedName: "default",
1292
+ kind: "default",
1293
+ localName: part,
1294
+ moduleSpecifier
1295
+ });
1296
+ }
1297
+ }
1298
+ match = importRegex.exec(content);
1299
+ }
1300
+ return bindings;
1301
+ }
1302
+ function parseReExportBindings(content) {
1303
+ const bindings = [];
1304
+ const exportRegex = /^\s*export\s+(?:type\s+)?(\*|\{[\s\S]*?\})\s+from\s+['"]([^'"]+)['"];?/gm;
1305
+ let match = exportRegex.exec(content);
1306
+ while (match !== null) {
1307
+ const body = match[1] ?? "";
1308
+ const moduleSpecifier = match[2] ?? "";
1309
+ if (body === "*") {
1310
+ bindings.push({ exportedName: "*", importedName: "*", moduleSpecifier });
1311
+ match = exportRegex.exec(content);
1312
+ continue;
1313
+ }
1314
+ bindings.push(
1315
+ ...parseNamedBindings(body).map((binding) => ({
1316
+ exportedName: binding.localName,
1317
+ importedName: binding.importedName,
1318
+ moduleSpecifier
1319
+ }))
1320
+ );
1321
+ match = exportRegex.exec(content);
1322
+ }
1323
+ return bindings;
1324
+ }
1325
+ function parseNamedBindings(body) {
1326
+ return body.slice(1, -1).split(",").map((part) => part.trim().replace(/^type\s+/, "")).filter(Boolean).map((part) => {
1327
+ const aliasMatch = part.match(/^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/);
1328
+ if (aliasMatch?.[1] && aliasMatch[2]) {
1329
+ return { importedName: aliasMatch[1], localName: aliasMatch[2] };
1330
+ }
1331
+ return new RegExp(`^${IDENTIFIER_PATTERN}$`).test(part) ? { importedName: part, localName: part } : null;
1332
+ }).filter((binding) => Boolean(binding));
1333
+ }
1334
+ function splitImportParts(body) {
1335
+ const parts = [];
1336
+ let depth = 0;
1337
+ let start = 0;
1338
+ for (let index = 0; index < body.length; index += 1) {
1339
+ const char = body[index];
1340
+ if (char === "{") {
1341
+ depth += 1;
1342
+ } else if (char === "}") {
1343
+ depth = Math.max(0, depth - 1);
1344
+ } else if (char === "," && depth === 0) {
1345
+ parts.push(body.slice(start, index).trim());
1346
+ start = index + 1;
1347
+ }
1348
+ }
1349
+ parts.push(body.slice(start).trim());
1350
+ return parts.filter(Boolean);
1351
+ }
1352
+ async function loadPathAliasConfig(repoRoot) {
1353
+ for (const configFile of PATH_CONFIG_FILES) {
1354
+ const rawConfig = await readFile3(path7.join(repoRoot, configFile), "utf8").catch(() => null);
1355
+ if (!rawConfig) {
1356
+ continue;
1357
+ }
1358
+ const parsed = parseJsonConfig(rawConfig);
1359
+ if (!parsed) {
1360
+ continue;
1361
+ }
1362
+ const compilerOptions = asRecord(parsed.compilerOptions);
1363
+ const baseUrlValue = compilerOptions ? compilerOptions.baseUrl : null;
1364
+ const baseUrl = typeof baseUrlValue === "string" ? path7.resolve(repoRoot, baseUrlValue) : repoRoot;
1365
+ const paths = asRecord(compilerOptions?.paths);
1366
+ const aliases = paths ? Object.entries(paths).flatMap(
1367
+ ([pattern, replacements]) => Array.isArray(replacements) ? [
1368
+ {
1369
+ pattern,
1370
+ replacements: replacements.filter(
1371
+ (replacement) => typeof replacement === "string"
1372
+ )
1373
+ }
1374
+ ] : []
1375
+ ) : [];
1376
+ return { aliases, baseUrl };
1377
+ }
1378
+ return { aliases: [], baseUrl: null };
1379
+ }
1380
+ function parseJsonConfig(rawConfig) {
1381
+ try {
1382
+ return JSON.parse(stripJsonComments(rawConfig).replace(/,\s*([}\]])/g, "$1"));
1383
+ } catch {
1384
+ return null;
1385
+ }
1386
+ }
1387
+ function stripJsonComments(input) {
1388
+ return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
1389
+ }
1390
+ function moduleCandidates(repoRoot, aliasConfig, sourceFilePath, moduleSpecifier) {
1391
+ const roots = [];
1392
+ if (moduleSpecifier.startsWith(".")) {
1393
+ roots.push(path7.resolve(repoRoot, path7.dirname(sourceFilePath), moduleSpecifier));
1394
+ } else {
1395
+ roots.push(...aliasCandidateRoots(repoRoot, aliasConfig, moduleSpecifier));
1396
+ if (aliasConfig.baseUrl) {
1397
+ roots.push(path7.resolve(aliasConfig.baseUrl, moduleSpecifier));
1398
+ }
1399
+ }
1400
+ const candidates = /* @__PURE__ */ new Set();
1401
+ for (const root of roots) {
1402
+ for (const candidate of expandModuleRoot(root)) {
1403
+ const relativePath = repoRelativePath(repoRoot, candidate);
1404
+ if (relativePath) {
1405
+ candidates.add(relativePath);
1406
+ }
1407
+ }
1408
+ }
1409
+ return [...candidates];
1410
+ }
1411
+ function aliasCandidateRoots(repoRoot, aliasConfig, moduleSpecifier) {
1412
+ const roots = [];
1413
+ for (const alias of aliasConfig.aliases) {
1414
+ const wildcardIndex = alias.pattern.indexOf("*");
1415
+ const matched = wildcardIndex >= 0 ? matchWildcardPattern(alias.pattern, wildcardIndex, moduleSpecifier) : alias.pattern === moduleSpecifier ? "" : null;
1416
+ if (matched === null) {
1417
+ continue;
1418
+ }
1419
+ for (const replacement of alias.replacements) {
1420
+ roots.push(
1421
+ path7.resolve(
1422
+ aliasConfig.baseUrl ?? repoRoot,
1423
+ wildcardIndex >= 0 ? replacement.replace("*", matched) : replacement
1424
+ )
1425
+ );
1426
+ }
1427
+ }
1428
+ return roots;
1429
+ }
1430
+ function matchWildcardPattern(pattern, wildcardIndex, value) {
1431
+ const prefix = pattern.slice(0, wildcardIndex);
1432
+ const suffix = pattern.slice(wildcardIndex + 1);
1433
+ if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
1434
+ return null;
1435
+ }
1436
+ return value.slice(prefix.length, value.length - suffix.length);
1437
+ }
1438
+ function expandModuleRoot(root) {
1439
+ const candidates = [];
1440
+ if (path7.extname(root)) {
1441
+ candidates.push(root);
1442
+ } else {
1443
+ for (const extension of MODULE_EXTENSIONS) {
1444
+ candidates.push(`${root}${extension}`);
1445
+ }
1446
+ for (const extension of MODULE_EXTENSIONS) {
1447
+ candidates.push(path7.join(root, `index${extension}`));
1448
+ }
1449
+ }
1450
+ return candidates;
1451
+ }
1452
+ async function firstExistingModuleFile(repoRoot, sourceRef, candidates) {
1453
+ for (const filePath of candidates) {
1454
+ const content = await readRepoText(repoRoot, sourceRef, filePath).catch(() => null);
1455
+ if (content !== null) {
1456
+ return { content, filePath };
1457
+ }
1458
+ }
1459
+ return null;
1460
+ }
1461
+ function findDefinition(content, symbol) {
1462
+ const lines = splitFileLines2(content);
1463
+ const patterns = definitionPatterns(symbol);
1464
+ for (let index = 0; index < lines.length; index += 1) {
1465
+ const line = lines[index] ?? "";
1466
+ if (!patterns.some((pattern) => pattern.test(line))) {
1467
+ continue;
1468
+ }
1469
+ return {
1470
+ column: Math.max(0, line.indexOf(symbol === "default" ? "default" : symbol)),
1471
+ line: index + 1
1472
+ };
1473
+ }
1474
+ return null;
1475
+ }
1476
+ function definitionPatterns(symbol) {
1477
+ if (symbol === "default") {
1478
+ return [/^\s*export\s+default\b/];
1479
+ }
1480
+ const escaped = escapeRegex(symbol);
1481
+ return [
1482
+ new RegExp(`^\\s*(?:export\\s+)?(?:declare\\s+)?(?:async\\s+)?function\\s+${escaped}\\b`),
1483
+ new RegExp(`^\\s*(?:export\\s+)?(?:declare\\s+)?(?:const|let|var)\\s+${escaped}\\b`),
1484
+ new RegExp(
1485
+ `^\\s*(?:export\\s+)?(?:declare\\s+)?(?:class|interface|type|enum)\\s+${escaped}\\b`
1486
+ ),
1487
+ new RegExp(`^\\s*export\\s+default\\s+(?:async\\s+)?function\\s+${escaped}\\b`),
1488
+ new RegExp(`^\\s*export\\s+default\\s+class\\s+${escaped}\\b`)
1489
+ ];
1490
+ }
1491
+ function definitionGrepPattern(symbol) {
1492
+ const escaped = escapeExtendedGrep(symbol);
1493
+ return [
1494
+ `^[[:space:]]*(export[[:space:]]+)?(declare[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+${escaped}([^[:alnum:]_$]|$)`,
1495
+ `^[[:space:]]*(export[[:space:]]+)?(declare[[:space:]]+)?(const|let|var)[[:space:]]+${escaped}([^[:alnum:]_$]|$)`,
1496
+ `^[[:space:]]*(export[[:space:]]+)?(declare[[:space:]]+)?(class|interface|type|enum)[[:space:]]+${escaped}([^[:alnum:]_$]|$)`
1497
+ ].join("|");
1498
+ }
1499
+ function firstMeaningfulLine(content) {
1500
+ const lines = splitFileLines2(content);
1501
+ const index = lines.findIndex((line) => line.trim() !== "");
1502
+ return index >= 0 ? index + 1 : 1;
1503
+ }
1504
+ function parseGrepLine(rawLine, sourceRef) {
1505
+ const line = sourceRef ? rawLine.slice(sourceRef.length + 1) : rawLine;
1506
+ const firstColon = line.indexOf(":");
1507
+ const secondColon = line.indexOf(":", firstColon + 1);
1508
+ if (firstColon < 0 || secondColon < 0) {
1509
+ return null;
1510
+ }
1511
+ const lineNumber = Number(line.slice(firstColon + 1, secondColon));
1512
+ if (!Number.isFinite(lineNumber)) {
1513
+ return null;
1514
+ }
1515
+ return {
1516
+ filePath: line.slice(0, firstColon),
1517
+ line: lineNumber
1518
+ };
1519
+ }
1520
+ function repoRelativePath(repoRoot, absolutePath) {
1521
+ const relativePath = path7.relative(repoRoot, absolutePath);
1522
+ if (!relativePath || relativePath.startsWith("..") || path7.isAbsolute(relativePath)) {
1523
+ return null;
1524
+ }
1525
+ return relativePath.split(path7.sep).join("/");
1526
+ }
1527
+ function splitFileLines2(contents) {
1528
+ const lines = contents.replace(/\r\n/g, "\n").split("\n");
1529
+ if (lines.at(-1) === "") {
1530
+ lines.pop();
1531
+ }
1532
+ return lines;
1533
+ }
1534
+ function asRecord(value) {
1535
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1536
+ }
1537
+ function isExitCode(error, exitCode) {
1538
+ return typeof error === "object" && error !== null && "exitCode" in error ? error.exitCode === exitCode : false;
1539
+ }
1540
+ function escapeRegex(value) {
1541
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1542
+ }
1543
+ function escapeExtendedGrep(value) {
1544
+ return value.replace(/[.[*^$()+?{}|\\]/g, "\\$&");
803
1545
  }
804
1546
 
805
1547
  // src/server/store.ts
806
1548
  import { createHash } from "crypto";
807
- import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
808
- import path6 from "path";
1549
+ import { readdir as readdir2, readFile as readFile5 } from "fs/promises";
1550
+ import path8 from "path";
809
1551
  import { ulid } from "ulid";
810
1552
 
811
1553
  // src/shared/cleanup.ts
812
- import { readdir, readFile as readFile3, rm as rm3 } from "fs/promises";
1554
+ import { readdir, readFile as readFile4, rm as rm3 } from "fs/promises";
813
1555
  var DEFAULT_REVIEW_RETENTION_DAYS = 30;
814
1556
  var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
815
1557
  var millisecondsPerDay = 24 * 60 * 60 * 1e3;
@@ -869,7 +1611,7 @@ function normalizeRetentionDays(value) {
869
1611
  async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
870
1612
  let raw;
871
1613
  try {
872
- raw = await readFile3(globalReviewMetaFile(reviewId), "utf8");
1614
+ raw = await readFile4(globalReviewMetaFile(reviewId), "utf8");
873
1615
  } catch (error) {
874
1616
  if (isFileNotFound(error)) {
875
1617
  skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
@@ -949,7 +1691,7 @@ async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
949
1691
  async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
950
1692
  let raw;
951
1693
  try {
952
- raw = await readFile3(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
1694
+ raw = await readFile4(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
953
1695
  } catch (error) {
954
1696
  skipped.push({
955
1697
  reviewId,
@@ -1476,7 +2218,7 @@ var ReviewStore = class {
1476
2218
  const metaPath = globalReviewMetaFile(id);
1477
2219
  let metaRaw;
1478
2220
  try {
1479
- metaRaw = await readFile4(metaPath, "utf8");
2221
+ metaRaw = await readFile5(metaPath, "utf8");
1480
2222
  } catch (error) {
1481
2223
  if (isFileNotFound(error)) {
1482
2224
  return this.loadReviewFromTurnsOnly(id);
@@ -1558,8 +2300,8 @@ var ReviewStore = class {
1558
2300
  let diffRaw;
1559
2301
  try {
1560
2302
  [metaRaw, diffRaw] = await Promise.all([
1561
- readFile4(metaPath, "utf8"),
1562
- readFile4(diffPath, "utf8")
2303
+ readFile5(metaPath, "utf8"),
2304
+ readFile5(diffPath, "utf8")
1563
2305
  ]);
1564
2306
  } catch (error) {
1565
2307
  if (isFileNotFound(error)) {
@@ -1589,7 +2331,7 @@ var ReviewStore = class {
1589
2331
  const diffPath = globalReviewDiffFile(id);
1590
2332
  let diffRaw;
1591
2333
  try {
1592
- diffRaw = await readFile4(diffPath, "utf8");
2334
+ diffRaw = await readFile5(diffPath, "utf8");
1593
2335
  } catch (error) {
1594
2336
  if (isFileNotFound(error)) {
1595
2337
  return null;
@@ -1769,9 +2511,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
1769
2511
  status,
1770
2512
  submittedAt: feedback?.timestamp ?? meta.submittedAt,
1771
2513
  resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1772
- feedbackPath: feedback ? meta.feedbackPath ?? path6.join(meta.artifactDir, "feedback.json") : void 0,
1773
- markdownPath: feedback ? meta.markdownPath ?? path6.join(meta.artifactDir, "feedback.md") : void 0,
1774
- resolvedPath: resolution ? meta.resolvedPath ?? path6.join(meta.artifactDir, "resolved.json") : void 0,
2514
+ feedbackPath: feedback ? meta.feedbackPath ?? path8.join(meta.artifactDir, "feedback.json") : void 0,
2515
+ markdownPath: feedback ? meta.markdownPath ?? path8.join(meta.artifactDir, "feedback.md") : void 0,
2516
+ resolvedPath: resolution ? meta.resolvedPath ?? path8.join(meta.artifactDir, "resolved.json") : void 0,
1775
2517
  diff,
1776
2518
  ...feedback ? { feedback } : {},
1777
2519
  ...resolution ? { resolution } : {}
@@ -1804,7 +2546,7 @@ function requiredPath(value, label) {
1804
2546
  async function readOptionalJsonFile(filePath, guard, label) {
1805
2547
  let raw;
1806
2548
  try {
1807
- raw = await readFile4(filePath, "utf8");
2549
+ raw = await readFile5(filePath, "utf8");
1808
2550
  } catch (error) {
1809
2551
  if (isFileNotFound(error)) {
1810
2552
  return void 0;
@@ -1849,6 +2591,10 @@ function createApp(origin2, options = {}) {
1849
2591
  };
1850
2592
  return c.json(response);
1851
2593
  });
2594
+ app.get("/api/open-targets", async (c) => {
2595
+ const response = { targets: await availableOpenFileTargets() };
2596
+ return c.json(response);
2597
+ });
1852
2598
  app.get("/api/reviews", async (c) => {
1853
2599
  const response = { reviews: await reviewStore.list() };
1854
2600
  return c.json(response);
@@ -2075,7 +2821,7 @@ function createApp(origin2, options = {}) {
2075
2821
  return c.json({ error: "turn not found" }, 404);
2076
2822
  }
2077
2823
  const diffPayload = turn?.diff ?? existing.diff;
2078
- const repoRoot = path7.resolve(diffPayload.cwd);
2824
+ const repoRoot = path9.resolve(diffPayload.cwd);
2079
2825
  const pathError = validateContextPath(repoRoot, body.filePath, "filePath") ?? (body.oldPath ? validateContextPath(repoRoot, body.oldPath, "oldPath") : null);
2080
2826
  if (pathError) {
2081
2827
  return c.json({ error: pathError }, 400);
@@ -2110,6 +2856,129 @@ function createApp(origin2, options = {}) {
2110
2856
  return c.json({ error: `context is unavailable: ${formatError(error)}` }, 409);
2111
2857
  }
2112
2858
  });
2859
+ app.post("/api/reviews/:id/source-peek", async (c) => {
2860
+ const id = c.req.param("id");
2861
+ const existing = await reviewStore.get(id);
2862
+ if (!existing) {
2863
+ return c.json({ error: "review not found" }, 404);
2864
+ }
2865
+ const parsed = await readJsonBody(c, isSourcePeekRequest, "source peek request");
2866
+ if (!parsed.ok) {
2867
+ return parsed.response;
2868
+ }
2869
+ const body = parsed.body;
2870
+ const turn = body.turnId ? await reviewStore.getTurn(id, body.turnId) : null;
2871
+ if (body.turnId && !turn) {
2872
+ return c.json({ error: "turn not found" }, 404);
2873
+ }
2874
+ const diffPayload = turn?.diff ?? existing.diff;
2875
+ const repoRoot = path9.resolve(diffPayload.cwd);
2876
+ const pathError = validateContextPath(repoRoot, body.filePath, "filePath") ?? (body.oldPath ? validateContextPath(repoRoot, body.oldPath, "oldPath") : null);
2877
+ if (pathError) {
2878
+ return c.json({ error: pathError }, 400);
2879
+ }
2880
+ const source = await resolveContextSource(diffPayload, body.source);
2881
+ if (!source.ok) {
2882
+ return c.json({ error: source.error }, source.status);
2883
+ }
2884
+ const reviewFile = source.files.find((file) => file.path === body.filePath);
2885
+ if (!reviewFile) {
2886
+ return c.json({ error: "file is not part of this review context" }, 404);
2887
+ }
2888
+ if ((reviewFile.oldPath ?? null) !== body.oldPath) {
2889
+ return c.json({ error: "oldPath does not match the reviewed file" }, 400);
2890
+ }
2891
+ if (reviewFile.isBinary) {
2892
+ return c.json({ error: "binary file source peek is not available" }, 409);
2893
+ }
2894
+ if (body.side === "L" && reviewFile.isNew) {
2895
+ return c.json({ error: "new files do not have an old-side source" }, 409);
2896
+ }
2897
+ if (body.side === "R" && reviewFile.isDeleted) {
2898
+ return c.json({ error: "deleted files do not have a new-side source" }, 409);
2899
+ }
2900
+ const sourceFilePath = body.side === "L" ? body.oldPath ?? body.filePath : body.filePath;
2901
+ const sourceRef = body.side === "L" ? source.oldRef : source.newRef;
2902
+ try {
2903
+ return c.json(
2904
+ await resolveSourcePeek({
2905
+ repoRoot,
2906
+ sourceFilePath,
2907
+ sourceRef,
2908
+ symbol: body.symbol,
2909
+ line: body.line,
2910
+ column: body.column
2911
+ })
2912
+ );
2913
+ } catch (error) {
2914
+ return c.json({ error: `source peek unavailable: ${formatError(error)}` }, 404);
2915
+ }
2916
+ });
2917
+ app.post("/api/reviews/:id/files/content", async (c) => {
2918
+ const id = c.req.param("id");
2919
+ const existing = await reviewStore.get(id);
2920
+ if (!existing) {
2921
+ return c.json({ error: "review not found" }, 404);
2922
+ }
2923
+ const parsed = await readJsonBody(c, isFileContentRequest, "file content request");
2924
+ if (!parsed.ok) {
2925
+ return parsed.response;
2926
+ }
2927
+ const { filePath, scope = "review", turnId } = parsed.body;
2928
+ if (!filePath || filePath.includes("\0") || path9.isAbsolute(filePath)) {
2929
+ return c.json({ error: "filePath must be a repo-relative path" }, 400);
2930
+ }
2931
+ const repoRoot = path9.resolve(existing.diff.cwd);
2932
+ const requestedAbsolutePath = path9.resolve(repoRoot, filePath);
2933
+ if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2934
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
2935
+ }
2936
+ const turn = turnId ? await reviewStore.getTurn(id, turnId) : null;
2937
+ if (turnId && !turn) {
2938
+ return c.json({ error: "turn not found" }, 404);
2939
+ }
2940
+ if (scope === "review") {
2941
+ const diffPayload = turn?.diff ?? existing.diff;
2942
+ const reviewFiles = [
2943
+ ...diffPayload.files,
2944
+ ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
2945
+ ].filter((file) => file.path === filePath);
2946
+ if (reviewFiles.length === 0) {
2947
+ return c.json({ error: "file is not part of this review" }, 404);
2948
+ }
2949
+ if (reviewFiles.every((file) => file.isDeleted)) {
2950
+ return c.json({ error: "deleted files cannot be copied" }, 409);
2951
+ }
2952
+ if (reviewFiles.some((file) => file.isBinary)) {
2953
+ return c.json({ error: "binary file contents cannot be copied" }, 409);
2954
+ }
2955
+ }
2956
+ let realRepoRoot;
2957
+ let realFilePath;
2958
+ try {
2959
+ [realRepoRoot, realFilePath] = await Promise.all([
2960
+ realpath(repoRoot),
2961
+ realpath(requestedAbsolutePath)
2962
+ ]);
2963
+ } catch (error) {
2964
+ if (isFileNotFound(error)) {
2965
+ return c.json({ error: "file no longer exists on disk" }, 404);
2966
+ }
2967
+ throw error;
2968
+ }
2969
+ if (!isPathWithin(realRepoRoot, realFilePath)) {
2970
+ return c.json({ error: "filePath must stay within the review cwd" }, 400);
2971
+ }
2972
+ const fileStats = await stat(realFilePath);
2973
+ if (!fileStats.isFile()) {
2974
+ return c.json({ error: "path is not a file" }, 409);
2975
+ }
2976
+ const response = {
2977
+ content: await readFile6(realFilePath, "utf8"),
2978
+ filePath
2979
+ };
2980
+ return c.json(response);
2981
+ });
2113
2982
  app.post("/api/reviews/:id/files/open", async (c) => {
2114
2983
  const id = c.req.param("id");
2115
2984
  const existing = await reviewStore.get(id);
@@ -2120,12 +2989,12 @@ function createApp(origin2, options = {}) {
2120
2989
  if (!parsed.ok) {
2121
2990
  return parsed.response;
2122
2991
  }
2123
- const { filePath, turnId } = parsed.body;
2124
- if (!filePath || filePath.includes("\0") || path7.isAbsolute(filePath)) {
2992
+ const { filePath, scope = "review", target, turnId } = parsed.body;
2993
+ if (!filePath || filePath.includes("\0") || path9.isAbsolute(filePath)) {
2125
2994
  return c.json({ error: "filePath must be a repo-relative path" }, 400);
2126
2995
  }
2127
- const repoRoot = path7.resolve(existing.diff.cwd);
2128
- const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
2996
+ const repoRoot = path9.resolve(existing.diff.cwd);
2997
+ const requestedAbsolutePath = path9.resolve(repoRoot, filePath);
2129
2998
  if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2130
2999
  return c.json({ error: "filePath must stay within the review cwd" }, 400);
2131
3000
  }
@@ -2133,16 +3002,18 @@ function createApp(origin2, options = {}) {
2133
3002
  if (turnId && !turn) {
2134
3003
  return c.json({ error: "turn not found" }, 404);
2135
3004
  }
2136
- const diffPayload = turn?.diff ?? existing.diff;
2137
- const reviewFiles = [
2138
- ...diffPayload.files,
2139
- ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
2140
- ].filter((file) => file.path === filePath);
2141
- if (reviewFiles.length === 0) {
2142
- return c.json({ error: "file is not part of this review" }, 404);
2143
- }
2144
- if (reviewFiles.every((file) => file.isDeleted)) {
2145
- return c.json({ error: "deleted files cannot be opened locally" }, 409);
3005
+ if (scope === "review") {
3006
+ const diffPayload = turn?.diff ?? existing.diff;
3007
+ const reviewFiles = [
3008
+ ...diffPayload.files,
3009
+ ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
3010
+ ].filter((file) => file.path === filePath);
3011
+ if (reviewFiles.length === 0) {
3012
+ return c.json({ error: "file is not part of this review" }, 404);
3013
+ }
3014
+ if (reviewFiles.every((file) => file.isDeleted)) {
3015
+ return c.json({ error: "deleted files cannot be opened locally" }, 409);
3016
+ }
2146
3017
  }
2147
3018
  let realRepoRoot;
2148
3019
  let realFilePath;
@@ -2165,7 +3036,7 @@ function createApp(origin2, options = {}) {
2165
3036
  return c.json({ error: "path is not a file" }, 409);
2166
3037
  }
2167
3038
  try {
2168
- await openLocalPath(realFilePath);
3039
+ target ? await openLocalPath(realFilePath, target) : await openLocalPath(realFilePath);
2169
3040
  } catch (error) {
2170
3041
  return c.json({ error: `could not open file: ${formatError(error)}` }, 500);
2171
3042
  }
@@ -2241,13 +3112,13 @@ function createApp(origin2, options = {}) {
2241
3112
  }
2242
3113
  async function serveAsset(c) {
2243
3114
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
2244
- const normalized = path7.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
2245
- const assetPath = path7.join(webRoot, "assets", normalized);
3115
+ const normalized = path9.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
3116
+ const assetPath = path9.join(webRoot, "assets", normalized);
2246
3117
  try {
2247
- const body = await readFile5(assetPath);
3118
+ const body = await readFile6(assetPath);
2248
3119
  return new Response(body, {
2249
3120
  headers: {
2250
- "content-type": mimeTypes[path7.extname(assetPath)] ?? "application/octet-stream"
3121
+ "content-type": mimeTypes[path9.extname(assetPath)] ?? "application/octet-stream"
2251
3122
  }
2252
3123
  });
2253
3124
  } catch (error) {
@@ -2259,7 +3130,7 @@ async function serveAsset(c) {
2259
3130
  }
2260
3131
  async function serveIndex() {
2261
3132
  try {
2262
- const body = await readFile5(path7.join(webRoot, "index.html"));
3133
+ const body = await readFile6(path9.join(webRoot, "index.html"));
2263
3134
  return new Response(body, {
2264
3135
  headers: { "content-type": "text/html; charset=utf-8" }
2265
3136
  });
@@ -2273,7 +3144,7 @@ async function serveIndex() {
2273
3144
  function serveRootFile(fileName, contentType) {
2274
3145
  return async () => {
2275
3146
  try {
2276
- const body = await readFile5(path7.join(webRoot, fileName));
3147
+ const body = await readFile6(path9.join(webRoot, fileName));
2277
3148
  return new Response(body, {
2278
3149
  headers: { "content-type": contentType }
2279
3150
  });
@@ -2305,8 +3176,8 @@ async function readJsonBody(c, guard, label) {
2305
3176
  }
2306
3177
  }
2307
3178
  function isPathWithin(parentPath, childPath) {
2308
- const relative = path7.relative(parentPath, childPath);
2309
- return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
3179
+ const relative = path9.relative(parentPath, childPath);
3180
+ return relative === "" || !relative.startsWith("..") && !path9.isAbsolute(relative);
2310
3181
  }
2311
3182
  async function resolveContextSource(diffPayload, source) {
2312
3183
  if (source.mode === "turn") {
@@ -2354,10 +3225,10 @@ async function resolveContextSource(diffPayload, source) {
2354
3225
  };
2355
3226
  }
2356
3227
  function validateContextPath(repoRoot, filePath, label) {
2357
- if (!filePath || filePath.includes("\0") || path7.isAbsolute(filePath)) {
3228
+ if (!filePath || filePath.includes("\0") || path9.isAbsolute(filePath)) {
2358
3229
  return `${label} must be a repo-relative path`;
2359
3230
  }
2360
- const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
3231
+ const requestedAbsolutePath = path9.resolve(repoRoot, filePath);
2361
3232
  if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2362
3233
  return `${label} must stay within the review cwd`;
2363
3234
  }