getgloss 0.11.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.11.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",
@@ -201,8 +201,44 @@ var DIFF_LINE_TYPES = ["context", "add", "delete"];
201
201
  var DIFF_SCOPE_MODES = ["working", "branch", "explicit"];
202
202
  var DIFF_FALLBACK_REASONS = ["working-tree-clean", "missing-branch-base"];
203
203
  var DIFF_CONTEXT_MAX_LINES = 500;
204
+ var SOURCE_PEEK_MAX_BYTES = 35e4;
204
205
  var REVIEW_SCOPE_MODES = ["all", "single", "range"];
205
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
+ ];
206
242
 
207
243
  // src/shared/validation.ts
208
244
  function parseJson(raw, guard, label) {
@@ -222,7 +258,10 @@ function isClearReviewsRequest(value) {
222
258
  return isRecord(value) && isOptionalNonNegativeInteger(value.olderThanDays) && isOptionalBoolean(value.dryRun);
223
259
  }
224
260
  function isOpenFileRequest(value) {
225
- 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);
226
265
  }
227
266
  function isCommitRangeDiffRequest(value) {
228
267
  return isRecord(value) && isString(value.fromSha) && isString(value.toSha) && isOptionalString(value.turnId);
@@ -230,6 +269,9 @@ function isCommitRangeDiffRequest(value) {
230
269
  function isDiffContextRequest(value) {
231
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);
232
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
+ }
233
275
  function isSubmitReviewRequest(value) {
234
276
  return isRecord(value) && isArrayOf(value.comments, isComment) && isOptional(value.reviewScope, isReviewScope);
235
277
  }
@@ -333,6 +375,12 @@ function isReviewStatus(value) {
333
375
  function isResolutionStatus(value) {
334
376
  return isOneOf(value, RESOLUTION_STATUSES);
335
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
+ }
336
384
  function isRecord(value) {
337
385
  return typeof value === "object" && value !== null && !Array.isArray(value);
338
386
  }
@@ -357,6 +405,12 @@ function isNumber(value) {
357
405
  function isPositiveInteger(value) {
358
406
  return isNumber(value) && Number.isInteger(value) && value > 0;
359
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
+ }
360
414
  function isOptionalNumber(value) {
361
415
  return value === void 0 || isNumber(value);
362
416
  }
@@ -515,8 +569,8 @@ function createIdleScheduler(options) {
515
569
  }
516
570
 
517
571
  // src/server/index.ts
518
- import { readFile as readFile5, realpath, stat } from "fs/promises";
519
- import path7 from "path";
572
+ import { readFile as readFile6, realpath, stat } from "fs/promises";
573
+ import path9 from "path";
520
574
  import { fileURLToPath } from "url";
521
575
  import { Hono } from "hono";
522
576
  import { streamSSE } from "hono/streaming";
@@ -798,19 +852,706 @@ function isResolvableReviewStatus(status) {
798
852
  }
799
853
 
800
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";
801
860
  import open from "open";
802
- async function openLocalPath(filePath) {
803
- 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, "\\$&");
804
1545
  }
805
1546
 
806
1547
  // src/server/store.ts
807
1548
  import { createHash } from "crypto";
808
- import { readdir as readdir2, readFile as readFile4 } from "fs/promises";
809
- import path6 from "path";
1549
+ import { readdir as readdir2, readFile as readFile5 } from "fs/promises";
1550
+ import path8 from "path";
810
1551
  import { ulid } from "ulid";
811
1552
 
812
1553
  // src/shared/cleanup.ts
813
- import { readdir, readFile as readFile3, rm as rm3 } from "fs/promises";
1554
+ import { readdir, readFile as readFile4, rm as rm3 } from "fs/promises";
814
1555
  var DEFAULT_REVIEW_RETENTION_DAYS = 30;
815
1556
  var clearableStatuses = /* @__PURE__ */ new Set(["submitted", "resolved", "cancelled"]);
816
1557
  var millisecondsPerDay = 24 * 60 * 60 * 1e3;
@@ -870,7 +1611,7 @@ function normalizeRetentionDays(value) {
870
1611
  async function cleanupCandidate(reviewId, artifactDir, cutoff, skipped) {
871
1612
  let raw;
872
1613
  try {
873
- raw = await readFile3(globalReviewMetaFile(reviewId), "utf8");
1614
+ raw = await readFile4(globalReviewMetaFile(reviewId), "utf8");
874
1615
  } catch (error) {
875
1616
  if (isFileNotFound(error)) {
876
1617
  skipped.push({ reviewId, artifactDir, reason: "missing metadata" });
@@ -950,7 +1691,7 @@ async function persistedTurnCleanupState(reviewId, artifactDir, skipped) {
950
1691
  async function readPersistedTurnMeta(reviewId, turnDirName, artifactDir, skipped) {
951
1692
  let raw;
952
1693
  try {
953
- raw = await readFile3(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
1694
+ raw = await readFile4(globalReviewTurnMetaFile(reviewId, turnDirName), "utf8");
954
1695
  } catch (error) {
955
1696
  skipped.push({
956
1697
  reviewId,
@@ -1477,7 +2218,7 @@ var ReviewStore = class {
1477
2218
  const metaPath = globalReviewMetaFile(id);
1478
2219
  let metaRaw;
1479
2220
  try {
1480
- metaRaw = await readFile4(metaPath, "utf8");
2221
+ metaRaw = await readFile5(metaPath, "utf8");
1481
2222
  } catch (error) {
1482
2223
  if (isFileNotFound(error)) {
1483
2224
  return this.loadReviewFromTurnsOnly(id);
@@ -1559,8 +2300,8 @@ var ReviewStore = class {
1559
2300
  let diffRaw;
1560
2301
  try {
1561
2302
  [metaRaw, diffRaw] = await Promise.all([
1562
- readFile4(metaPath, "utf8"),
1563
- readFile4(diffPath, "utf8")
2303
+ readFile5(metaPath, "utf8"),
2304
+ readFile5(diffPath, "utf8")
1564
2305
  ]);
1565
2306
  } catch (error) {
1566
2307
  if (isFileNotFound(error)) {
@@ -1590,7 +2331,7 @@ var ReviewStore = class {
1590
2331
  const diffPath = globalReviewDiffFile(id);
1591
2332
  let diffRaw;
1592
2333
  try {
1593
- diffRaw = await readFile4(diffPath, "utf8");
2334
+ diffRaw = await readFile5(diffPath, "utf8");
1594
2335
  } catch (error) {
1595
2336
  if (isFileNotFound(error)) {
1596
2337
  return null;
@@ -1770,9 +2511,9 @@ function reconcileTurn(meta, diff, feedback, resolution) {
1770
2511
  status,
1771
2512
  submittedAt: feedback?.timestamp ?? meta.submittedAt,
1772
2513
  resolvedAt: status === "resolved" ? resolution?.resolvedAt ?? meta.resolvedAt : void 0,
1773
- feedbackPath: feedback ? meta.feedbackPath ?? path6.join(meta.artifactDir, "feedback.json") : void 0,
1774
- markdownPath: feedback ? meta.markdownPath ?? path6.join(meta.artifactDir, "feedback.md") : void 0,
1775
- 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,
1776
2517
  diff,
1777
2518
  ...feedback ? { feedback } : {},
1778
2519
  ...resolution ? { resolution } : {}
@@ -1805,7 +2546,7 @@ function requiredPath(value, label) {
1805
2546
  async function readOptionalJsonFile(filePath, guard, label) {
1806
2547
  let raw;
1807
2548
  try {
1808
- raw = await readFile4(filePath, "utf8");
2549
+ raw = await readFile5(filePath, "utf8");
1809
2550
  } catch (error) {
1810
2551
  if (isFileNotFound(error)) {
1811
2552
  return void 0;
@@ -1850,6 +2591,10 @@ function createApp(origin2, options = {}) {
1850
2591
  };
1851
2592
  return c.json(response);
1852
2593
  });
2594
+ app.get("/api/open-targets", async (c) => {
2595
+ const response = { targets: await availableOpenFileTargets() };
2596
+ return c.json(response);
2597
+ });
1853
2598
  app.get("/api/reviews", async (c) => {
1854
2599
  const response = { reviews: await reviewStore.list() };
1855
2600
  return c.json(response);
@@ -2076,7 +2821,7 @@ function createApp(origin2, options = {}) {
2076
2821
  return c.json({ error: "turn not found" }, 404);
2077
2822
  }
2078
2823
  const diffPayload = turn?.diff ?? existing.diff;
2079
- const repoRoot = path7.resolve(diffPayload.cwd);
2824
+ const repoRoot = path9.resolve(diffPayload.cwd);
2080
2825
  const pathError = validateContextPath(repoRoot, body.filePath, "filePath") ?? (body.oldPath ? validateContextPath(repoRoot, body.oldPath, "oldPath") : null);
2081
2826
  if (pathError) {
2082
2827
  return c.json({ error: pathError }, 400);
@@ -2111,6 +2856,129 @@ function createApp(origin2, options = {}) {
2111
2856
  return c.json({ error: `context is unavailable: ${formatError(error)}` }, 409);
2112
2857
  }
2113
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
+ });
2114
2982
  app.post("/api/reviews/:id/files/open", async (c) => {
2115
2983
  const id = c.req.param("id");
2116
2984
  const existing = await reviewStore.get(id);
@@ -2121,12 +2989,12 @@ function createApp(origin2, options = {}) {
2121
2989
  if (!parsed.ok) {
2122
2990
  return parsed.response;
2123
2991
  }
2124
- const { filePath, turnId } = parsed.body;
2125
- 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)) {
2126
2994
  return c.json({ error: "filePath must be a repo-relative path" }, 400);
2127
2995
  }
2128
- const repoRoot = path7.resolve(existing.diff.cwd);
2129
- const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
2996
+ const repoRoot = path9.resolve(existing.diff.cwd);
2997
+ const requestedAbsolutePath = path9.resolve(repoRoot, filePath);
2130
2998
  if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2131
2999
  return c.json({ error: "filePath must stay within the review cwd" }, 400);
2132
3000
  }
@@ -2134,16 +3002,18 @@ function createApp(origin2, options = {}) {
2134
3002
  if (turnId && !turn) {
2135
3003
  return c.json({ error: "turn not found" }, 404);
2136
3004
  }
2137
- const diffPayload = turn?.diff ?? existing.diff;
2138
- const reviewFiles = [
2139
- ...diffPayload.files,
2140
- ...(diffPayload.commitDiffs ?? []).flatMap((commitDiff) => commitDiff.files)
2141
- ].filter((file) => file.path === filePath);
2142
- if (reviewFiles.length === 0) {
2143
- return c.json({ error: "file is not part of this review" }, 404);
2144
- }
2145
- if (reviewFiles.every((file) => file.isDeleted)) {
2146
- 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
+ }
2147
3017
  }
2148
3018
  let realRepoRoot;
2149
3019
  let realFilePath;
@@ -2166,7 +3036,7 @@ function createApp(origin2, options = {}) {
2166
3036
  return c.json({ error: "path is not a file" }, 409);
2167
3037
  }
2168
3038
  try {
2169
- await openLocalPath(realFilePath);
3039
+ target ? await openLocalPath(realFilePath, target) : await openLocalPath(realFilePath);
2170
3040
  } catch (error) {
2171
3041
  return c.json({ error: `could not open file: ${formatError(error)}` }, 500);
2172
3042
  }
@@ -2242,13 +3112,13 @@ function createApp(origin2, options = {}) {
2242
3112
  }
2243
3113
  async function serveAsset(c) {
2244
3114
  const requestPath = new URL(c.req.url).pathname.replace(/^\/assets\//, "");
2245
- const normalized = path7.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
2246
- const assetPath = path7.join(webRoot, "assets", normalized);
3115
+ const normalized = path9.normalize(requestPath).replace(/^(\.\.(\/|\\|$))+/, "");
3116
+ const assetPath = path9.join(webRoot, "assets", normalized);
2247
3117
  try {
2248
- const body = await readFile5(assetPath);
3118
+ const body = await readFile6(assetPath);
2249
3119
  return new Response(body, {
2250
3120
  headers: {
2251
- "content-type": mimeTypes[path7.extname(assetPath)] ?? "application/octet-stream"
3121
+ "content-type": mimeTypes[path9.extname(assetPath)] ?? "application/octet-stream"
2252
3122
  }
2253
3123
  });
2254
3124
  } catch (error) {
@@ -2260,7 +3130,7 @@ async function serveAsset(c) {
2260
3130
  }
2261
3131
  async function serveIndex() {
2262
3132
  try {
2263
- const body = await readFile5(path7.join(webRoot, "index.html"));
3133
+ const body = await readFile6(path9.join(webRoot, "index.html"));
2264
3134
  return new Response(body, {
2265
3135
  headers: { "content-type": "text/html; charset=utf-8" }
2266
3136
  });
@@ -2274,7 +3144,7 @@ async function serveIndex() {
2274
3144
  function serveRootFile(fileName, contentType) {
2275
3145
  return async () => {
2276
3146
  try {
2277
- const body = await readFile5(path7.join(webRoot, fileName));
3147
+ const body = await readFile6(path9.join(webRoot, fileName));
2278
3148
  return new Response(body, {
2279
3149
  headers: { "content-type": contentType }
2280
3150
  });
@@ -2306,8 +3176,8 @@ async function readJsonBody(c, guard, label) {
2306
3176
  }
2307
3177
  }
2308
3178
  function isPathWithin(parentPath, childPath) {
2309
- const relative = path7.relative(parentPath, childPath);
2310
- return relative === "" || !relative.startsWith("..") && !path7.isAbsolute(relative);
3179
+ const relative = path9.relative(parentPath, childPath);
3180
+ return relative === "" || !relative.startsWith("..") && !path9.isAbsolute(relative);
2311
3181
  }
2312
3182
  async function resolveContextSource(diffPayload, source) {
2313
3183
  if (source.mode === "turn") {
@@ -2355,10 +3225,10 @@ async function resolveContextSource(diffPayload, source) {
2355
3225
  };
2356
3226
  }
2357
3227
  function validateContextPath(repoRoot, filePath, label) {
2358
- if (!filePath || filePath.includes("\0") || path7.isAbsolute(filePath)) {
3228
+ if (!filePath || filePath.includes("\0") || path9.isAbsolute(filePath)) {
2359
3229
  return `${label} must be a repo-relative path`;
2360
3230
  }
2361
- const requestedAbsolutePath = path7.resolve(repoRoot, filePath);
3231
+ const requestedAbsolutePath = path9.resolve(repoRoot, filePath);
2362
3232
  if (!isPathWithin(repoRoot, requestedAbsolutePath)) {
2363
3233
  return `${label} must stay within the review cwd`;
2364
3234
  }