@qwen-code/arena-agent 0.0.10-dev.1 → 0.0.10-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.env.example +68 -14
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +307 -57
  4. package/dist/agent.d.ts +28 -2
  5. package/dist/agent.js +389 -109
  6. package/dist/agent.js.map +1 -1
  7. package/dist/anthropic.js +32 -35
  8. package/dist/anthropic.js.map +1 -1
  9. package/dist/ask-user.js +2 -0
  10. package/dist/ask-user.js.map +1 -1
  11. package/dist/attachments.js +4 -3
  12. package/dist/attachments.js.map +1 -1
  13. package/dist/brave-search.js +1 -3
  14. package/dist/brave-search.js.map +1 -1
  15. package/dist/canonical-data.d.ts +2 -0
  16. package/dist/canonical-data.js +4 -2
  17. package/dist/canonical-data.js.map +1 -1
  18. package/dist/cli.js +184 -36
  19. package/dist/cli.js.map +1 -1
  20. package/dist/code-tools.d.ts +8 -2
  21. package/dist/code-tools.js +709 -117
  22. package/dist/code-tools.js.map +1 -1
  23. package/dist/compaction.d.ts +50 -3
  24. package/dist/compaction.js +390 -185
  25. package/dist/compaction.js.map +1 -1
  26. package/dist/config.d.ts +23 -2
  27. package/dist/config.js +52 -5
  28. package/dist/config.js.map +1 -1
  29. package/dist/connector-mcp.d.ts +47 -0
  30. package/dist/connector-mcp.js +314 -0
  31. package/dist/connector-mcp.js.map +1 -0
  32. package/dist/connectors.d.ts +8 -0
  33. package/dist/connectors.js +57 -0
  34. package/dist/connectors.js.map +1 -0
  35. package/dist/fetch-page.js +9 -33
  36. package/dist/fetch-page.js.map +1 -1
  37. package/dist/file-edit.d.ts +8 -0
  38. package/dist/file-edit.js +63 -0
  39. package/dist/file-edit.js.map +1 -0
  40. package/dist/history.d.ts +16 -3
  41. package/dist/history.js +117 -19
  42. package/dist/history.js.map +1 -1
  43. package/dist/midturn-checkpoint-hook.d.ts +44 -0
  44. package/dist/midturn-checkpoint-hook.js +38 -0
  45. package/dist/midturn-checkpoint-hook.js.map +1 -0
  46. package/dist/mock.js +5 -0
  47. package/dist/mock.js.map +1 -1
  48. package/dist/mode.js +4 -2
  49. package/dist/mode.js.map +1 -1
  50. package/dist/model-router-media.d.ts +1 -0
  51. package/dist/model-router-media.js +121 -1
  52. package/dist/model-router-media.js.map +1 -1
  53. package/dist/openai-responses.js +2 -0
  54. package/dist/openai-responses.js.map +1 -1
  55. package/dist/process-manager.js +2 -1
  56. package/dist/process-manager.js.map +1 -1
  57. package/dist/prompts.d.ts +2 -1
  58. package/dist/prompts.js +15 -37
  59. package/dist/prompts.js.map +1 -1
  60. package/dist/protocol.d.ts +5 -1
  61. package/dist/protocol.js +24 -2
  62. package/dist/protocol.js.map +1 -1
  63. package/dist/provider.d.ts +12 -5
  64. package/dist/provider.js +63 -52
  65. package/dist/provider.js.map +1 -1
  66. package/dist/repetitive-output.d.ts +12 -0
  67. package/dist/repetitive-output.js +80 -0
  68. package/dist/repetitive-output.js.map +1 -0
  69. package/dist/replay-responses.d.ts +17 -0
  70. package/dist/replay-responses.js +100 -0
  71. package/dist/replay-responses.js.map +1 -0
  72. package/dist/routify-web-fetch.js +20 -7
  73. package/dist/routify-web-fetch.js.map +1 -1
  74. package/dist/sandbox-file-write.d.ts +10 -0
  75. package/dist/sandbox-file-write.js +58 -0
  76. package/dist/sandbox-file-write.js.map +1 -0
  77. package/dist/server.js +17 -5
  78. package/dist/server.js.map +1 -1
  79. package/dist/session-memory-prompts.d.ts +5 -0
  80. package/dist/session-memory-prompts.js +7 -0
  81. package/dist/session-memory-prompts.js.map +1 -0
  82. package/dist/snapshot-client.d.ts +10 -0
  83. package/dist/snapshot-client.js +39 -0
  84. package/dist/snapshot-client.js.map +1 -0
  85. package/dist/spec.d.ts +5 -4
  86. package/dist/spec.js +31 -17
  87. package/dist/spec.js.map +1 -1
  88. package/dist/speech-audio.d.ts +9 -0
  89. package/dist/speech-audio.js +130 -0
  90. package/dist/speech-audio.js.map +1 -0
  91. package/dist/speech-model.d.ts +3 -0
  92. package/dist/speech-model.js +8 -0
  93. package/dist/speech-model.js.map +1 -0
  94. package/dist/summary-compaction.d.ts +21 -0
  95. package/dist/summary-compaction.js +211 -0
  96. package/dist/summary-compaction.js.map +1 -0
  97. package/dist/tools.d.ts +8 -1
  98. package/dist/tools.js +407 -135
  99. package/dist/tools.js.map +1 -1
  100. package/dist/tui.js +13 -10
  101. package/dist/tui.js.map +1 -1
  102. package/dist/types.d.ts +30 -3
  103. package/dist/voice-registry.d.ts +2 -0
  104. package/dist/voice-registry.js +10 -2
  105. package/dist/voice-registry.js.map +1 -1
  106. package/dist/web-fetch-output.d.ts +9 -0
  107. package/dist/web-fetch-output.js +38 -0
  108. package/dist/web-fetch-output.js.map +1 -0
  109. package/dist/workspace.d.ts +2 -0
  110. package/dist/workspace.js +11 -6
  111. package/dist/workspace.js.map +1 -1
  112. package/docs/BINARY_DISTRIBUTION.md +27 -16
  113. package/docs/MODES_AND_RELEASES.md +60 -6
  114. package/docs/SDK.md +5 -0
  115. package/modes/code-arena/capture-provenance.json +57 -0
  116. package/modes/code-arena/full-stack/system-prompt.txt +1 -1
  117. package/modes/code-arena/full-stack/template/eslint.config.mjs +3 -4
  118. package/modes/code-arena/full-stack/template/postcss.config.mjs +2 -2
  119. package/modes/code-arena/full-stack/tools.json +186 -186
  120. package/modes/code-arena/html/system-prompt.txt +1 -1
  121. package/modes/code-arena/html/tools.json +123 -123
  122. package/modes/code-arena/react/system-prompt.txt +4 -3
  123. package/modes/code-arena/react/template/package.json +4 -4
  124. package/modes/code-arena/react/template/src/App.tsx +1 -1
  125. package/modes/code-arena/react/template/src/main.tsx +1 -1
  126. package/modes/code-arena/react/template/tsconfig.json +6 -0
  127. package/modes/code-arena/react/template/vite.config.ts +1 -0
  128. package/modes/code-arena/react/tools.json +178 -184
  129. package/package.json +15 -4
  130. package/scripts/README.md +80 -0
  131. package/scripts/check-changelog.mjs +66 -0
  132. package/vendor/audio/darwin-arm64/FFmpeg.LICENSE +502 -0
  133. package/vendor/audio/darwin-arm64/LAME.LICENSE +481 -0
  134. package/vendor/audio/darwin-arm64/Opus.LICENSE +44 -0
  135. package/vendor/audio/darwin-arm64/ffmpeg.gz +0 -0
  136. package/vendor/audio/darwin-arm64/manifest.json +39 -0
  137. package/vendor/audio/darwin-x64/FFmpeg.LICENSE +502 -0
  138. package/vendor/audio/darwin-x64/LAME.LICENSE +481 -0
  139. package/vendor/audio/darwin-x64/Opus.LICENSE +44 -0
  140. package/vendor/audio/darwin-x64/ffmpeg.gz +0 -0
  141. package/vendor/audio/darwin-x64/manifest.json +39 -0
  142. package/vendor/audio/linux-arm64/FFmpeg.LICENSE +502 -0
  143. package/vendor/audio/linux-arm64/LAME.LICENSE +481 -0
  144. package/vendor/audio/linux-arm64/Opus.LICENSE +44 -0
  145. package/vendor/audio/linux-arm64/ffmpeg.gz +0 -0
  146. package/vendor/audio/linux-arm64/manifest.json +39 -0
  147. package/vendor/audio/linux-x64/FFmpeg.LICENSE +502 -0
  148. package/vendor/audio/linux-x64/LAME.LICENSE +481 -0
  149. package/vendor/audio/linux-x64/Opus.LICENSE +44 -0
  150. package/vendor/audio/linux-x64/ffmpeg.gz +0 -0
  151. package/vendor/audio/linux-x64/manifest.json +39 -0
  152. package/vendor/audio/sources/build-audio-encoder.sh +76 -0
  153. package/vendor/audio/sources/ffmpeg-7.0.2.tar.xz +0 -0
  154. package/vendor/audio/sources/lame-3.100.tar.gz +0 -0
  155. package/vendor/audio/sources/opus-1.5.2.tar.gz +0 -0
  156. package/vendor/audio/sources/pkgconf-2.3.0.tar.xz +0 -0
  157. package/vendor/audio/sources/sources.lock.json +30 -0
  158. package/vendor/audio/win32-x64/FFmpeg.LICENSE +502 -0
  159. package/vendor/audio/win32-x64/LAME.LICENSE +481 -0
  160. package/vendor/audio/win32-x64/Opus.LICENSE +44 -0
  161. package/vendor/audio/win32-x64/ffmpeg.gz +0 -0
  162. package/vendor/audio/win32-x64/manifest.json +39 -0
@@ -12,10 +12,14 @@ import iconv from "iconv-lite";
12
12
  import mime from "mime/lite";
13
13
  import { executeBraveWebSearch } from "./brave-search.js";
14
14
  import { FetchPageReader } from "./fetch-page.js";
15
- import { generatedImageBytesForExtension } from "./image.js";
15
+ import { replaceFirstText, replaceUniqueText } from "./file-edit.js";
16
+ import { detectedImageMimeType, generatedImageBytesForExtension } from "./image.js";
17
+ import { supportsImageInput } from "./model-capabilities.js";
16
18
  import { generateImageWithModelRouter, } from "./model-router-media.js";
17
19
  import { executeRoutifyWebFetch, routifyWebFetchEnabled, } from "./routify-web-fetch.js";
20
+ import { replayResponseFor } from "./replay-responses.js";
18
21
  import { toolDefinitions } from "./spec.js";
22
+ import { boundedWebFetchResult, truncateCodeArenaText } from "./web-fetch-output.js";
19
23
  const knownTextApplicationMimeTypes = new Set([
20
24
  "application/ecmascript",
21
25
  "application/javascript",
@@ -184,6 +188,42 @@ const visibleImageExtensions = new Set([
184
188
  ".png",
185
189
  ".webp",
186
190
  ]);
191
+ const readableImageExtensions = new Set([".jpeg", ".jpg", ".png"]);
192
+ const maximumReadableImageBytes = 10 * 1024 * 1024;
193
+ function readableImageMimeType(data) {
194
+ if (data.length >= 8 &&
195
+ data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
196
+ return "image/png";
197
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
198
+ return "image/jpeg";
199
+ return undefined;
200
+ }
201
+ function hasReadableImageStructure(data, mediaType) {
202
+ if (detectedImageMimeType(data) !== mediaType)
203
+ return false;
204
+ if (mediaType === "image/jpeg") {
205
+ return data.length >= 4 && data[data.length - 2] === 0xff && data[data.length - 1] === 0xd9;
206
+ }
207
+ if (data.readUInt32BE(16) === 0 || data.readUInt32BE(20) === 0)
208
+ return false;
209
+ let offset = 8;
210
+ let hasImageData = false;
211
+ while (offset + 12 <= data.length) {
212
+ const length = data.readUInt32BE(offset);
213
+ const end = offset + 12 + length;
214
+ if (end > data.length)
215
+ return false;
216
+ const type = data.toString("ascii", offset + 4, offset + 8);
217
+ if (offset === 8 && (type !== "IHDR" || length !== 13))
218
+ return false;
219
+ if (type === "IDAT")
220
+ hasImageData = true;
221
+ if (type === "IEND")
222
+ return length === 0 && hasImageData;
223
+ offset = end;
224
+ }
225
+ return false;
226
+ }
187
227
  // All 39 successful, unmutated list_files captures use this exact order,
188
228
  // independently of whether the requested path is the project root or src/.
189
229
  // It is the seed manifest order, not an alphabetical/filesystem order.
@@ -229,6 +269,7 @@ const visualIntroText = "<code-harness-system-message>\n" +
229
269
  "</code-harness-system-message>";
230
270
  const bashOutputLimit = 20_000;
231
271
  const lintOutputLimit = 10_000;
272
+ const grepLineCharacterLimit = 2_000;
232
273
  // 中段截断,逐字反推自 3 个 truncated:true 参考样本:头尾各 9987 字符 +
233
274
  // 26 字符标记,总长恰 20000(由 T1(b) 执行回放发现,非首段截断)。
234
275
  const bashTruncationMarker = "\n... output truncated ...\n";
@@ -379,6 +420,14 @@ function workspaceFileKind(filePath, sample) {
379
420
  function json(value) {
380
421
  return JSON.stringify(value);
381
422
  }
423
+ function capGrepLine(value) {
424
+ // Production captures cap every returned grep line independently. This is
425
+ // intentionally a prefix without a marker, including for the matching line.
426
+ return value.slice(0, grepLineCharacterLimit);
427
+ }
428
+ function capGrepLines(values) {
429
+ return values.map(capGrepLine);
430
+ }
382
431
  function errorMessage(error) {
383
432
  return error instanceof Error ? error.message : String(error);
384
433
  }
@@ -392,6 +441,17 @@ function stringValue(value) {
392
441
  }
393
442
  function contentTypeForFile(filePath) {
394
443
  const extension = path.extname(filePath).toLowerCase();
444
+ const capturedTypes = {
445
+ ".py": "text/x-python",
446
+ ".sh": "text/x-shellscript",
447
+ ".sql": "application/sql",
448
+ ".yaml": "application/x-yaml",
449
+ ".yml": "application/x-yaml",
450
+ ".mjs": "text/plain",
451
+ ".c": "text/plain",
452
+ };
453
+ if (capturedTypes[extension])
454
+ return capturedTypes[extension];
395
455
  if ([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"].includes(extension)) {
396
456
  return "text/javascript";
397
457
  }
@@ -450,9 +510,14 @@ function matchesGlob(filePath, pattern) {
450
510
  matcher.test(path.posix.basename(normalized))));
451
511
  });
452
512
  }
453
- function packageNameIsSafe(value) {
513
+ function packageNameIsSafe(value, mode) {
454
514
  if (!value || value.startsWith("-") || /\s/.test(value))
455
515
  return false;
516
+ if (mode === "react") {
517
+ // React captures reject version ranges before invoking npm, while accepting
518
+ // exact versions and tags such as latest. Keep other modes' validator intact.
519
+ return /^(?:@[\w.-]+\/)?[\w.-]+(?:@[\w.+-]+)?$/.test(value);
520
+ }
456
521
  return /^(?:@[\w.-]+\/)?[\w.-]+(?:@(?:[\w*^<>=|.+-]+))?$/.test(value);
457
522
  }
458
523
  function valueAtPath(value, instancePath) {
@@ -524,6 +589,17 @@ function validationIssues(toolName, value, errors, definitions) {
524
589
  continue;
525
590
  }
526
591
  const actual = missing ? undefined : valueAtPath(value, error.instancePath);
592
+ if (error.keyword === "enum") {
593
+ const options = record(error.params).allowedValues;
594
+ issues.push({
595
+ received: actual,
596
+ code: "invalid_enum_value",
597
+ options,
598
+ path: pathParts,
599
+ message: `Invalid enum value. Expected ${options.map((option) => `'${option}'`).join(" | ")}, received '${actual}'`,
600
+ });
601
+ continue;
602
+ }
527
603
  const expected = error.keyword === "type"
528
604
  ? String(record(error.params).type || "string")
529
605
  : requiredPropertyType(toolName, key, definitions);
@@ -638,6 +714,57 @@ function interleaveMedia(left, right) {
638
714
  function normalizeMediaQuery(value) {
639
715
  return value.trim().replace(/\s+/g, " ").toLowerCase();
640
716
  }
717
+ // Captured sessions call apply_patch even though no recorded request ever
718
+ // advertised it: six react calls (s05/s17/s33) and 260+ html calls executed
719
+ // successfully against the untouched 13-tool/7-tool contracts, while one h19
720
+ // call was rejected by a turn whose runtime lacked it. The canonical schemas
721
+ // stay untouched; the executor accepts the call the way the recording
722
+ // environment's runtime did.
723
+ const APPLY_PATCH_PARAMETERS = {
724
+ type: "object",
725
+ properties: {
726
+ input: { type: "string" },
727
+ },
728
+ required: ["input"],
729
+ additionalProperties: false,
730
+ };
731
+ const APPLY_PATCH_DESCRIPTION = "Apply a patch to files in the project. The patch uses the codex patch format: " +
732
+ "'*** Begin Patch' ... '*** End Patch', with '*** Add File: <path>' (content as " +
733
+ "'+'-prefixed lines), '*** Update File: <path>' ('@@'-separated hunks with ' ' " +
734
+ "context, '-' removed and '+' added lines), and '*** Delete File: <path>'.";
735
+ // CODE_HARNESS_APPLY_PATCH_TOOL opts into advertising the tool to the model.
736
+ // It stays out of the tool list by default while the executor still accepts
737
+ // the call, matching the recorded runtime.
738
+ function applyPatchToolAdvertised() {
739
+ const value = process.env.CODE_HARNESS_APPLY_PATCH_TOOL;
740
+ return (value !== undefined && !["0", "false", "no"].includes(value.toLowerCase()));
741
+ }
742
+ function locateHunkContext(lines, findLines, fromIndex) {
743
+ const limit = lines.length - findLines.length;
744
+ for (let start = Math.max(0, fromIndex); start <= limit; start += 1) {
745
+ let matched = true;
746
+ for (let offset = 0; offset < findLines.length; offset += 1) {
747
+ if (lines[start + offset] !== findLines[offset]) {
748
+ matched = false;
749
+ break;
750
+ }
751
+ }
752
+ if (matched)
753
+ return start;
754
+ }
755
+ for (let start = Math.max(0, fromIndex); start <= limit; start += 1) {
756
+ let matched = true;
757
+ for (let offset = 0; offset < findLines.length; offset += 1) {
758
+ if (lines[start + offset].trim() !== findLines[offset].trim()) {
759
+ matched = false;
760
+ break;
761
+ }
762
+ }
763
+ if (matched)
764
+ return start;
765
+ }
766
+ return undefined;
767
+ }
641
768
  export class CodeToolExecutor {
642
769
  config;
643
770
  workspace;
@@ -671,15 +798,38 @@ export class CodeToolExecutor {
671
798
  this.workspaceContext = options.workspaceContext;
672
799
  this.pageReader = new FetchPageReader({ signal: options.signal });
673
800
  const disallowed = new Set(config.disallowedTools || []);
674
- this.definitions = toolDefinitions(this.workspaceContext, config.mode).filter((definition) => !disallowed.has(definition.function.name));
801
+ const canonical = toolDefinitions(this.workspaceContext, config.mode).filter((definition) => !disallowed.has(definition.function.name));
802
+ const advertiseApplyPatch = applyPatchToolAdvertised() && !disallowed.has("apply_patch");
803
+ this.definitions = advertiseApplyPatch
804
+ ? [
805
+ ...canonical,
806
+ {
807
+ type: "function",
808
+ function: {
809
+ name: "apply_patch",
810
+ description: APPLY_PATCH_DESCRIPTION,
811
+ parameters: APPLY_PATCH_PARAMETERS,
812
+ },
813
+ },
814
+ ]
815
+ : canonical;
675
816
  this.seedFileRank = capturedSeedFileRank(config.mode);
676
817
  addFormats(this.ajv);
677
818
  for (const definition of this.definitions) {
678
819
  this.validators.set(definition.function.name, this.ajv.compile(definition.function.parameters));
679
820
  }
821
+ // Hidden mode: advertise nothing, but keep the call executable.
822
+ if (!advertiseApplyPatch && !disallowed.has("apply_patch")) {
823
+ this.validators.set("apply_patch", this.ajv.compile(APPLY_PATCH_PARAMETERS));
824
+ }
825
+ // Captured React/HTML histories call this hidden tool. No public schema was
826
+ // captured: accept its object payload without adding model-visible tools.
827
+ if (config.mode !== "full-stack" && !disallowed.has("update_plan")) {
828
+ this.validators.set("update_plan", this.ajv.compile({ type: "object" }));
829
+ }
680
830
  }
681
- // The tool schemas offered to the model for this session. Identical to the
682
- // canonical trace set, adjusted for the selected workspace runtime.
831
+ // The tools offered to the model for this session, with captured schemas
832
+ // and descriptions adjusted for current capabilities and workspace runtime.
683
833
  availableTools() {
684
834
  return structuredClone(this.definitions);
685
835
  }
@@ -687,6 +837,128 @@ export class CodeToolExecutor {
687
837
  const names = this.definitions.map((tool) => tool.function.name);
688
838
  return `Model tried to call unavailable tool '${name}'. Available tools: ${names.join(", ")}.`;
689
839
  }
840
+ rawArguments(call) {
841
+ return typeof call.function?.arguments === "string"
842
+ ? call.function.arguments
843
+ : "{}";
844
+ }
845
+ normalizeArguments(name, parsedArguments) {
846
+ // Keep the parsed value available to checkpoint hooks while applying the
847
+ // same captured runtime defaults to the independent dispatch value.
848
+ const argumentsValue = (this.config.mode === "react" && typeof parsedArguments === "string"
849
+ ? {}
850
+ : structuredClone(parsedArguments));
851
+ // The captured provider advertises these fields as required, but its
852
+ // runtime still applies the documented defaults when a model omits
853
+ // them. Preserve the byte-identical outgoing schema while matching the
854
+ // observed execution behavior.
855
+ if (name === "read_file") {
856
+ argumentsValue.offset ??= 1;
857
+ argumentsValue.limit ??= 2000;
858
+ }
859
+ else if (name === "list_files" || name === "glob_files") {
860
+ argumentsValue.path ??= "";
861
+ }
862
+ else if (name === "bash" &&
863
+ argumentsValue && typeof argumentsValue === "object" &&
864
+ !Array.isArray(argumentsValue)) {
865
+ if (argumentsValue.workdir === undefined)
866
+ argumentsValue.workdir = "";
867
+ if (argumentsValue.description === undefined)
868
+ argumentsValue.description = "";
869
+ }
870
+ else if (name === "grep_files") {
871
+ argumentsValue.path ??= "";
872
+ argumentsValue.glob ??= "";
873
+ argumentsValue.output_mode ??= "content";
874
+ argumentsValue["-i"] ??= false;
875
+ argumentsValue["-B"] ??= 0;
876
+ argumentsValue["-A"] ??= 0;
877
+ argumentsValue["-C"] ??= 0;
878
+ argumentsValue.context ??= 0;
879
+ }
880
+ return argumentsValue;
881
+ }
882
+ validationMessage(name, value, validate) {
883
+ if (validate(value))
884
+ return undefined;
885
+ const issues = validationIssues(name, value, validate.errors || [], this.definitions);
886
+ return (`Invalid input for tool ${name}: Type validation failed: ` +
887
+ `Value: ${JSON.stringify(value)}.\n` +
888
+ `Error message: ${JSON.stringify(issues, null, 2)}`);
889
+ }
890
+ inspect(call) {
891
+ const id = String(call.id || "");
892
+ const name = String(call.function?.name || "");
893
+ const rawArguments = this.rawArguments(call);
894
+ const validate = this.validators.get(name);
895
+ if (!validate) {
896
+ let parsedArguments = undefined;
897
+ let parsed = false;
898
+ try {
899
+ parsedArguments = JSON.parse(rawArguments);
900
+ parsed = true;
901
+ }
902
+ catch {
903
+ // Tool-name validation takes precedence. Parsing is best-effort here
904
+ // only so a valid argument payload remains available to the hook.
905
+ }
906
+ return {
907
+ id,
908
+ name,
909
+ rawArguments,
910
+ ...(parsed ? { arguments: parsedArguments } : {}),
911
+ validation: "unknown_tool",
912
+ validationMessage: this.unavailableToolMessage(name),
913
+ };
914
+ }
915
+ let parsedArguments;
916
+ try {
917
+ parsedArguments = JSON.parse(rawArguments);
918
+ }
919
+ catch (error) {
920
+ return {
921
+ id,
922
+ name,
923
+ rawArguments,
924
+ validation: "invalid_json",
925
+ validationMessage: `Invalid input for tool ${name}: JSON parsing failed: Text: ` +
926
+ `${rawArguments}.\nError message: ${errorMessage(error)}`,
927
+ };
928
+ }
929
+ let argumentsValue;
930
+ try {
931
+ argumentsValue = this.normalizeArguments(name, parsedArguments);
932
+ }
933
+ catch (error) {
934
+ return {
935
+ id,
936
+ name,
937
+ rawArguments,
938
+ arguments: parsedArguments,
939
+ validation: "invalid_arguments",
940
+ validationMessage: errorMessage(error),
941
+ };
942
+ }
943
+ const validationMessage = this.validationMessage(name, argumentsValue, validate);
944
+ if (validationMessage !== undefined) {
945
+ return {
946
+ id,
947
+ name,
948
+ rawArguments,
949
+ arguments: parsedArguments,
950
+ validation: "invalid_arguments",
951
+ validationMessage,
952
+ };
953
+ }
954
+ return {
955
+ id,
956
+ name,
957
+ rawArguments,
958
+ arguments: parsedArguments,
959
+ validation: "valid",
960
+ };
961
+ }
690
962
  async execute(call, _context) {
691
963
  const callId = String(call.id || "");
692
964
  const name = String(call.function?.name || "");
@@ -695,27 +967,8 @@ export class CodeToolExecutor {
695
967
  // 监听器不会补发已发生的 abort:已取消的 signal 必须在产生任何副作用
696
968
  // 之前短路,否则命令会照常 spawn 直到自然结束。
697
969
  this.signal?.throwIfAborted();
698
- const argumentsValue = JSON.parse(typeof call.function?.arguments === "string"
699
- ? call.function.arguments
700
- : "{}");
701
- // The captured provider advertises these fields as required, but its
702
- // runtime still applies the documented defaults when a model omits
703
- // them. Preserve the byte-identical outgoing schema while matching the
704
- // observed execution behavior.
705
- if (name === "read_file") {
706
- argumentsValue.offset ??= 1;
707
- argumentsValue.limit ??= 2000;
708
- }
709
- else if (name === "grep_files") {
710
- argumentsValue.path ??= "";
711
- argumentsValue.glob ??= "";
712
- argumentsValue.output_mode ??= "content";
713
- argumentsValue["-i"] ??= false;
714
- argumentsValue["-B"] ??= 0;
715
- argumentsValue["-A"] ??= 0;
716
- argumentsValue["-C"] ??= 0;
717
- argumentsValue.context ??= 0;
718
- }
970
+ const parsedArguments = JSON.parse(this.rawArguments(call));
971
+ const argumentsValue = this.normalizeArguments(name, parsedArguments);
719
972
  const validate = this.validators.get(name);
720
973
  if (!validate) {
721
974
  return {
@@ -724,14 +977,12 @@ export class CodeToolExecutor {
724
977
  content: this.unavailableToolMessage(name),
725
978
  };
726
979
  }
727
- if (!validate(argumentsValue)) {
728
- const issues = validationIssues(name, argumentsValue, validate.errors || [], this.definitions);
980
+ const validationMessage = this.validationMessage(name, argumentsValue, validate);
981
+ if (validationMessage !== undefined) {
729
982
  return {
730
983
  role: "tool",
731
984
  tool_call_id: callId,
732
- content: `Invalid input for tool ${name}: Type validation failed: ` +
733
- `Value: ${JSON.stringify(argumentsValue)}.\n` +
734
- `Error message: ${JSON.stringify(issues, null, 2)}`,
985
+ content: validationMessage,
735
986
  };
736
987
  }
737
988
  const result = await this.dispatch(name, argumentsValue, callId);
@@ -770,13 +1021,24 @@ export class CodeToolExecutor {
770
1021
  };
771
1022
  }
772
1023
  async dispatch(name, values, _toolCallId) {
1024
+ // Replay mode (CODE_HARNESS_REPLAY_RESPONSES set): serve recorded outputs
1025
+ // verbatim for nondeterministic tools (web_search/web_fetch/fetch_media/…)
1026
+ // so replayed sessions stay offline and deterministic. Unlisted calls fall
1027
+ // through to real execution.
1028
+ const replayed = replayResponseFor(name, values);
1029
+ if (replayed)
1030
+ return replayed.output;
773
1031
  switch (name) {
774
1032
  case "read_file":
775
- return this.readCodeFile(String(values.path), Number(values.offset), Number(values.limit));
1033
+ return this.readCodeFile(String(values.path), Number(values.offset), Number(values.limit), _toolCallId);
776
1034
  case "create_file":
777
1035
  return this.createCodeFile(String(values.path), String(values.content));
778
1036
  case "edit_file":
779
1037
  return this.editCodeFile(String(values.path), String(values.context), String(values.replacement));
1038
+ case "apply_patch":
1039
+ return this.applyCodePatch(String(values.input));
1040
+ case "update_plan":
1041
+ return { status: "success" };
780
1042
  case "delete_file":
781
1043
  return this.deleteCodeFile(String(values.path));
782
1044
  case "list_files":
@@ -790,7 +1052,7 @@ export class CodeToolExecutor {
790
1052
  case "build_project":
791
1053
  return this.buildProject();
792
1054
  case "bash":
793
- return this.fullStackBash(String(values.command), String(values.workdir));
1055
+ return this.codeBash(String(values.command), String(values.workdir));
794
1056
  case "build_and_start":
795
1057
  return this.buildAndStart();
796
1058
  case "web_search":
@@ -806,8 +1068,9 @@ export class CodeToolExecutor {
806
1068
  }
807
1069
  }
808
1070
  lexicalPath(raw) {
809
- if (!raw)
810
- throw new Error("path must not be empty");
1071
+ if (!raw || path.isAbsolute(raw) || raw.includes("..")) {
1072
+ throw new Error("Path cannot be empty, start with '/', or contain '..'");
1073
+ }
811
1074
  let candidate;
812
1075
  if (!this.workspaceContext && (raw === "/home/user" || raw === "~")) {
813
1076
  candidate = this.workspace;
@@ -852,8 +1115,7 @@ export class CodeToolExecutor {
852
1115
  return path.relative(this.workspaceReal, target).split(path.sep).join("/");
853
1116
  }
854
1117
  rememberListedFile(filePath) {
855
- if (this.seedFileRank.has(filePath) ||
856
- this.listedFileOrder.has(filePath)) {
1118
+ if (this.seedFileRank.has(filePath) || this.listedFileOrder.has(filePath)) {
857
1119
  return;
858
1120
  }
859
1121
  this.listedFileOrder.set(filePath, this.nextListedFileOrder);
@@ -888,7 +1150,7 @@ export class CodeToolExecutor {
888
1150
  return -1;
889
1151
  if (rightSeedRank !== undefined)
890
1152
  return 1;
891
- return (this.listedFileOrder.get(left) - this.listedFileOrder.get(right));
1153
+ return this.listedFileOrder.get(left) - this.listedFileOrder.get(right);
892
1154
  });
893
1155
  }
894
1156
  async existsWithExactCase(rawPath) {
@@ -944,7 +1206,7 @@ export class CodeToolExecutor {
944
1206
  await visit(searchRoot);
945
1207
  return files;
946
1208
  }
947
- async readCodeFile(rawPath, offset, limit) {
1209
+ async readCodeFile(rawPath, offset, limit, toolCallId) {
948
1210
  const target = await this.confinedPath(rawPath);
949
1211
  let data;
950
1212
  try {
@@ -958,29 +1220,80 @@ export class CodeToolExecutor {
958
1220
  message: `File "${rawPath}" does not exist`,
959
1221
  };
960
1222
  }
1223
+ if (readableImageExtensions.has(path.extname(target).toLowerCase())) {
1224
+ const mediaType = readableImageMimeType(data);
1225
+ if (!mediaType) {
1226
+ return {
1227
+ status: "error",
1228
+ message: `"${rawPath}" is not a valid image — the file contents are not PNG or JPEG. Re-download or regenerate the file, then read it again.`,
1229
+ };
1230
+ }
1231
+ if (!supportsImageInput(this.config)) {
1232
+ return {
1233
+ status: "error",
1234
+ message: `This model cannot view images. Reference "${rawPath}" by its path instead.`,
1235
+ };
1236
+ }
1237
+ if (data.length > maximumReadableImageBytes) {
1238
+ return {
1239
+ status: "error",
1240
+ message: `Image "${rawPath}" is too large to view (${Math.round(data.length / 1024)} KB, max 10240 KB).`,
1241
+ };
1242
+ }
1243
+ if (!hasReadableImageStructure(data, mediaType)) {
1244
+ return {
1245
+ status: "error",
1246
+ message: `"${rawPath}" is not a valid image — the file contents are not PNG or JPEG. Re-download or regenerate the file, then read it again.`,
1247
+ };
1248
+ }
1249
+ const relativePath = this.relativePath(target);
1250
+ this.pendingVisualContent.set(toolCallId, [
1251
+ { image: `data:${mediaType};base64,${data.toString("base64")}` },
1252
+ ]);
1253
+ return {
1254
+ file: {
1255
+ content: `[Image file ${relativePath} (${Math.round(data.length / 1024)} KB, ${mediaType})]`,
1256
+ contentType: "text/plain",
1257
+ path: relativePath,
1258
+ },
1259
+ image: { mediaType, sizeBytes: data.length },
1260
+ status: "success",
1261
+ };
1262
+ }
961
1263
  if (workspaceFileKind(target, data.subarray(0, 4096)) !== "text") {
962
1264
  return {
963
1265
  status: "error",
964
- message: `File "${rawPath}" is not a supported text file`,
1266
+ message: `Cannot read binary file "${rawPath}". Only text files and PNG/JPEG images are supported.`,
965
1267
  };
966
1268
  }
967
1269
  const lines = decodeText(data).split("\n");
968
1270
  const start = Math.max(1, Math.trunc(offset));
1271
+ if (start > lines.length) {
1272
+ return {
1273
+ status: "success",
1274
+ file: {
1275
+ path: this.relativePath(target),
1276
+ content: `Offset ${start} is out of range for this file (${lines.length} lines)`,
1277
+ contentType: contentTypeForFile(target),
1278
+ },
1279
+ totalLines: lines.length,
1280
+ };
1281
+ }
969
1282
  const end = Math.min(lines.length, start + Math.max(1, Math.trunc(limit)) - 1);
970
- const numbered = start > lines.length
971
- ? ""
972
- : lines
973
- .slice(start - 1, end)
974
- .map((line, index) => `${start + index}: ${line}`)
975
- .join("\n");
976
- const footer = start === 1 && end === lines.length
1283
+ const numbered = lines
1284
+ .slice(start - 1, end)
1285
+ .map((line, index) => `${start + index}: ${line}`)
1286
+ .join("\n");
1287
+ const footer = end === lines.length
977
1288
  ? `(End of file - ${lines.length} lines)`
978
1289
  : `(Showing lines ${start}-${end} of ${lines.length}. Use offset=${end + 1} to continue.)`;
979
1290
  return {
980
1291
  status: "success",
981
1292
  file: {
982
1293
  path: this.relativePath(target),
983
- content: `${numbered}\n\n${footer}`,
1294
+ content: this.config.mode === "react"
1295
+ ? truncateCodeArenaText(`${numbered}\n\n${footer}`)
1296
+ : `${numbered}\n\n${footer}`,
984
1297
  contentType: contentTypeForFile(target),
985
1298
  },
986
1299
  totalLines: lines.length,
@@ -1063,49 +1376,272 @@ export class CodeToolExecutor {
1063
1376
  message: `File "${rawPath}" does not exist`,
1064
1377
  };
1065
1378
  }
1066
- if (!source.includes(context)) {
1379
+ // React replaces the first exact occurrence, including when an indented
1380
+ // context also occurs on a later, more deeply indented line. Keep the
1381
+ // existing uniqueness check for fuzzy fallback and the other modes.
1382
+ const replace = this.config.mode === "react" && context.length > 0 && source.includes(context)
1383
+ ? replaceFirstText : replaceUniqueText;
1384
+ const edited = replace(source, context, replacement, {
1385
+ whitespaceInsensitiveMultiline: true,
1386
+ whitespaceInsensitiveSingleLine: this.config.mode === "react",
1387
+ preserveNewlines: this.config.mode === "react",
1388
+ });
1389
+ if (edited === undefined) {
1390
+ // Captured sessions re-issue an edit after compaction or a retry. The
1391
+ // provider reports success without touching the file when the context
1392
+ // is already gone and the replacement is in place (f39 re-issues four
1393
+ // dashboard edits this way).
1394
+ // That retry behavior comes from Full Stack captures. React still
1395
+ // reports Context not found even when the replacement is present.
1396
+ if (this.config.mode !== "react" && replacement && source.includes(replacement)) {
1397
+ return this.mutationResult("Edited", rawPath);
1398
+ }
1067
1399
  return {
1068
1400
  status: "error",
1069
1401
  message: "Context not found. Read the file to verify the text exists.",
1070
1402
  };
1071
1403
  }
1072
- await writeFile(target, source.replace(context, replacement), "utf8");
1404
+ await writeFile(target, edited, "utf8");
1073
1405
  return this.mutationResult("Edited", rawPath);
1074
1406
  }
1407
+ // Some recorded sessions exposed a codex-style apply_patch tool (*** Begin
1408
+ // Patch / Add|Update|Delete File / @@ hunks) that the provider executed
1409
+ // without ever advertising it. Its observable contract: "Patch applied[.
1410
+ // Type errors found.]" on success, "Invalid Context <anchor>:\n<context>"
1411
+ // when a hunk context cannot be located — the anchor is the line where the
1412
+ // previous hunk's match ended and the failed search started (h17 reports
1413
+ // 666 after a four-line context hunk matching lines 663-666).
1414
+ async applyCodePatch(patchText) {
1415
+ const kindByMarker = {
1416
+ Add: "add",
1417
+ Update: "update",
1418
+ Delete: "delete",
1419
+ };
1420
+ const operations = [];
1421
+ let current;
1422
+ for (const line of patchText.split("\n")) {
1423
+ const marker = /^\*\*\* (Add|Update|Delete) File: (.+)$/.exec(line);
1424
+ if (marker) {
1425
+ current = {
1426
+ kind: kindByMarker[marker[1]],
1427
+ path: marker[2].trim(),
1428
+ lines: [],
1429
+ };
1430
+ operations.push(current);
1431
+ continue;
1432
+ }
1433
+ if (line.startsWith("*** ")) {
1434
+ current = undefined;
1435
+ continue;
1436
+ }
1437
+ current?.lines.push(line);
1438
+ }
1439
+ if (this.config.mode === "react") {
1440
+ // React parses duplicate operation paths before applying any writes.
1441
+ // In particular, Delete + Add of the same file is an Add duplicate.
1442
+ const seen = new Set();
1443
+ for (const operation of operations) {
1444
+ const target = await this.confinedPath(operation.path);
1445
+ if (seen.has(target)) {
1446
+ const label = operation.kind[0].toUpperCase() + operation.kind.slice(1);
1447
+ return { status: "error", message: `${label} File Error: Duplicate Path: ${operation.path}` };
1448
+ }
1449
+ seen.add(target);
1450
+ }
1451
+ }
1452
+ for (const operation of operations) {
1453
+ const target = await this.confinedPath(operation.path);
1454
+ if (operation.kind === "add") {
1455
+ if (await this.existsWithExactCase(operation.path)) {
1456
+ return {
1457
+ status: "error",
1458
+ message: `Add File Error: Duplicate Path: ${operation.path}`,
1459
+ };
1460
+ }
1461
+ // The recorded provider writes exactly the "+" lines joined by
1462
+ // newlines without appending a trailing one (h17's 27-line
1463
+ // manifest.json reads back as totalLines 27, not 28).
1464
+ const content = operation.lines
1465
+ .filter((line) => line.startsWith("+"))
1466
+ .map((line) => line.slice(1))
1467
+ .join("\n");
1468
+ mkdirSync(path.dirname(target), { recursive: true });
1469
+ await writeFile(target, content, "utf8");
1470
+ this.rememberListedFile(this.relativePath(target));
1471
+ continue;
1472
+ }
1473
+ if (operation.kind === "delete") {
1474
+ if (!(await this.existsWithExactCase(operation.path))) {
1475
+ return {
1476
+ status: "error",
1477
+ message: `Delete File Error: File Not Found: ${operation.path}`,
1478
+ };
1479
+ }
1480
+ await rm(target, { force: true });
1481
+ this.forgetListedPath(this.relativePath(target));
1482
+ continue;
1483
+ }
1484
+ let source;
1485
+ try {
1486
+ if (!(await this.existsWithExactCase(operation.path)))
1487
+ throw new Error("missing");
1488
+ source = await readFile(target, "utf8");
1489
+ }
1490
+ catch {
1491
+ return {
1492
+ status: "error",
1493
+ message: `Update File Error: File Not Found: ${operation.path}`,
1494
+ };
1495
+ }
1496
+ // Hunks are separated by @@ lines; within a hunk, " " context and "-"
1497
+ // removed lines form the find pattern, " " context and "+" added lines
1498
+ // form the replacement. Hunks stream over the original file: each
1499
+ // context is searched forward from where the previous hunk ended, and a
1500
+ // miss reports the search anchor over the find text the way the
1501
+ // recorded provider does (h17: "Invalid Context 666:"). Whitespace
1502
+ // drifted contexts match by trimmed lines.
1503
+ const hunks = [];
1504
+ let hunk = [];
1505
+ for (const line of operation.lines) {
1506
+ if (line.startsWith("@@")) {
1507
+ if (hunk.length)
1508
+ hunks.push(hunk);
1509
+ hunk = [];
1510
+ continue;
1511
+ }
1512
+ hunk.push(line);
1513
+ }
1514
+ if (hunk.length)
1515
+ hunks.push(hunk);
1516
+ const originalLines = source.split("\n");
1517
+ const output = [];
1518
+ let position = 0;
1519
+ for (const hunkLines of hunks) {
1520
+ const findLines = [];
1521
+ const replaceLines = [];
1522
+ for (const line of hunkLines) {
1523
+ if (line.startsWith("-")) {
1524
+ findLines.push(line.slice(1));
1525
+ }
1526
+ else if (line.startsWith("+")) {
1527
+ replaceLines.push(line.slice(1));
1528
+ }
1529
+ else {
1530
+ findLines.push(line.slice(1));
1531
+ replaceLines.push(line.slice(1));
1532
+ }
1533
+ }
1534
+ if (!findLines.length)
1535
+ continue;
1536
+ const start = locateHunkContext(originalLines, findLines, position);
1537
+ if (start === undefined) {
1538
+ return {
1539
+ status: "error",
1540
+ message: `Invalid Context ${position}:\n${findLines.join("\n")}`,
1541
+ };
1542
+ }
1543
+ output.push(...originalLines.slice(position, start));
1544
+ output.push(...replaceLines);
1545
+ position = start + findLines.length;
1546
+ }
1547
+ output.push(...originalLines.slice(position));
1548
+ await writeFile(target, output.join("\n"), "utf8");
1549
+ }
1550
+ const lintError = await this.typeErrors();
1551
+ if (lintError) {
1552
+ return {
1553
+ status: "success",
1554
+ message: "Patch applied. Type errors found.",
1555
+ lint_error: lintError,
1556
+ };
1557
+ }
1558
+ return { status: "success", message: "Patch applied." };
1559
+ }
1075
1560
  async deleteCodeFile(rawPath) {
1076
1561
  const target = await this.confinedPath(rawPath);
1077
1562
  await rm(target, { force: true, recursive: true });
1078
1563
  this.forgetListedPath(this.relativePath(target));
1079
1564
  return { status: "success" };
1080
1565
  }
1566
+ discoverableFile(filePath) {
1567
+ // Full Stack captures expose the lock to shell/read_file, but omit it
1568
+ // from both project discovery tools, even before any package installation.
1569
+ return this.config.mode !== "full-stack" || this.relativePath(filePath) !== "package-lock.json";
1570
+ }
1081
1571
  async listCodeFiles(rawPath) {
1572
+ if (this.config.mode === "react" && rawPath === "/")
1573
+ rawPath = ".";
1574
+ if (this.config.mode === "react" && (path.isAbsolute(rawPath) || rawPath.includes(".."))) {
1575
+ return { status: "error", message: "Path must be relative to project root (no absolute paths or '..' traversal)" };
1576
+ }
1082
1577
  const target = await this.confinedPath(rawPath || ".");
1083
1578
  if (!(await this.existsWithExactCase(rawPath || ".")) ||
1084
1579
  !existsSync(target)) {
1085
1580
  return { status: "success", files: [] };
1086
1581
  }
1087
- const files = this.orderListedFiles(await this.walkFiles(target));
1582
+ const files = this.orderListedFiles((await this.walkFiles(target)).filter(file => this.discoverableFile(file)));
1088
1583
  return {
1089
1584
  status: "success",
1090
- files: files.map((filePath) => ({ path: filePath })),
1585
+ files: (this.config.mode === "react" ? files.slice(0, 500) : files)
1586
+ .map((filePath) => ({ path: filePath })),
1091
1587
  };
1092
1588
  }
1093
1589
  async grepCodeFiles(values) {
1590
+ const pattern = String(values.pattern);
1591
+ if (pattern.length > 200) {
1592
+ return {
1593
+ status: "error",
1594
+ message: "Pattern too long (max 200 characters)",
1595
+ };
1596
+ }
1094
1597
  const target = await this.confinedPath(String(values.path || "."));
1095
- const targetInfo = await stat(target);
1096
- const files = await this.walkFiles(target);
1097
1598
  const glob = String(values.glob || "");
1599
+ const mode = String(values.output_mode || "content");
1098
1600
  const flags = Boolean(values["-i"]) ? "i" : "";
1099
1601
  let expression;
1100
1602
  try {
1101
- expression = new RegExp(String(values.pattern), flags);
1603
+ expression = new RegExp(pattern, flags);
1102
1604
  }
1103
1605
  catch (error) {
1104
1606
  throw new Error(`Invalid regular expression: ${errorMessage(error)}`);
1105
1607
  }
1106
- const symmetric = Math.max(Number(values.context || 0), Number(values["-C"] || 0));
1608
+ let targetInfo;
1609
+ try {
1610
+ targetInfo = await stat(target);
1611
+ }
1612
+ catch (error) {
1613
+ if (error.code !== "ENOENT")
1614
+ throw error;
1615
+ if (mode === "files_with_matches") {
1616
+ return { status: "success", mode, files: [], truncated: false };
1617
+ }
1618
+ if (mode === "count") {
1619
+ return {
1620
+ status: "success",
1621
+ mode,
1622
+ counts: [],
1623
+ totalMatches: 0,
1624
+ truncated: false,
1625
+ };
1626
+ }
1627
+ return {
1628
+ status: "success",
1629
+ mode: "content",
1630
+ matches: [],
1631
+ truncated: false,
1632
+ };
1633
+ }
1634
+ const files = await this.walkFiles(target);
1635
+ // Full Stack captures expose a different context-grouping adapter from
1636
+ // HTML/React: -C/context are ignored, the trailing group spans B+A lines,
1637
+ // and a later match reuses the prior trailing group as contextBefore.
1638
+ const groupedContext = this.config.mode === "full-stack";
1639
+ const symmetric = groupedContext
1640
+ ? 0
1641
+ : Math.max(Number(values.context || 0), Number(values["-C"] || 0));
1107
1642
  const before = symmetric || Number(values["-B"] || 0);
1108
1643
  const after = symmetric || Number(values["-A"] || 0);
1644
+ const contextSpan = groupedContext ? before + after : after;
1109
1645
  const matches = [];
1110
1646
  const matchedFiles = [];
1111
1647
  const counts = [];
@@ -1115,6 +1651,11 @@ export class CodeToolExecutor {
1115
1651
  const relativeToSearch = targetInfo.isFile()
1116
1652
  ? path.basename(filePath)
1117
1653
  : path.relative(target, filePath).split(path.sep).join("/");
1654
+ if (relativeToSearch
1655
+ .split("/")
1656
+ .some((component) => component.startsWith("."))) {
1657
+ continue;
1658
+ }
1118
1659
  if (glob && !matchesGlob(relativeToSearch, glob))
1119
1660
  continue;
1120
1661
  let content;
@@ -1129,23 +1670,69 @@ export class CodeToolExecutor {
1129
1670
  continue;
1130
1671
  }
1131
1672
  const lines = content.split("\n");
1132
- let fileCount = 0;
1133
- for (let index = 0; index < lines.length; index += 1) {
1134
- if (!expression.test(lines[index]))
1135
- continue;
1136
- fileCount += 1;
1673
+ const searchLineCount = this.config.mode === "react" && lines.at(-1) === ""
1674
+ ? lines.length - 1 : lines.length;
1675
+ const matchingLines = [];
1676
+ for (let index = 0; index < searchLineCount; index += 1) {
1677
+ if (expression.test(lines[index]))
1678
+ matchingLines.push(index);
1679
+ }
1680
+ const fileCount = matchingLines.length;
1681
+ let previousAfter = [];
1682
+ for (let position = 0; position < matchingLines.length; position += 1) {
1683
+ const index = matchingLines[position];
1137
1684
  totalMatches += 1;
1138
1685
  if (matches.length < maximumSearchResults) {
1139
1686
  const match = {
1140
1687
  path: relative,
1141
1688
  lineNumber: index + 1,
1142
- lineContent: lines[index],
1689
+ lineContent: capGrepLine(lines[index]),
1143
1690
  };
1144
- if (before > 0) {
1145
- match.contextBefore = lines.slice(Math.max(0, index - before), index);
1691
+ if (groupedContext) {
1692
+ const contextBefore = position === 0
1693
+ ? lines.slice(Math.max(0, index - before), index)
1694
+ : previousAfter;
1695
+ if (contextBefore.length > 0) {
1696
+ match.contextBefore = capGrepLines(contextBefore);
1697
+ }
1698
+ const nextMatch = matchingLines[position + 1] ?? lines.length;
1699
+ previousAfter = lines.slice(index + 1, Math.min(index + 1 + contextSpan, nextMatch));
1700
+ if (nextMatch === lines.length &&
1701
+ previousAfter.at(-1) === "" &&
1702
+ index + 1 + contextSpan >= lines.length) {
1703
+ previousAfter = previousAfter.slice(0, -1);
1704
+ }
1705
+ if (previousAfter.length > 0) {
1706
+ match.contextAfter = capGrepLines(previousAfter);
1707
+ }
1146
1708
  }
1147
- if (after > 0) {
1148
- match.contextAfter = lines.slice(index + 1, index + 1 + after);
1709
+ else if (this.config.mode === "react") {
1710
+ // The captured adapter attaches context lines to the preceding
1711
+ // match, even across ripgrep group separators. Only a file's first
1712
+ // match receives contextBefore. Do not duplicate overlapping groups.
1713
+ if (position === 0 && before > 0) {
1714
+ const contextBefore = lines.slice(Math.max(0, index - before), index);
1715
+ if (contextBefore.length) {
1716
+ match.contextBefore = capGrepLines(contextBefore);
1717
+ }
1718
+ }
1719
+ const nextMatch = matchingLines[position + 1];
1720
+ const end = Math.min(index + 1 + after, nextMatch ?? searchLineCount);
1721
+ const contextAfter = lines.slice(index + 1, end);
1722
+ if (nextMatch !== undefined && before > 0) {
1723
+ contextAfter.push(...lines.slice(Math.max(end, index + 1, nextMatch - before), nextMatch));
1724
+ }
1725
+ if (contextAfter.length) {
1726
+ match.contextAfter = capGrepLines(contextAfter);
1727
+ }
1728
+ }
1729
+ else {
1730
+ if (before > 0) {
1731
+ match.contextBefore = capGrepLines(lines.slice(Math.max(0, index - before), index));
1732
+ }
1733
+ if (after > 0) {
1734
+ match.contextAfter = capGrepLines(lines.slice(index + 1, index + 1 + after));
1735
+ }
1149
1736
  }
1150
1737
  matches.push(match);
1151
1738
  }
@@ -1158,7 +1745,6 @@ export class CodeToolExecutor {
1158
1745
  }
1159
1746
  }
1160
1747
  }
1161
- const mode = String(values.output_mode || "content");
1162
1748
  if (mode === "files_with_matches") {
1163
1749
  return {
1164
1750
  status: "success",
@@ -1185,9 +1771,18 @@ export class CodeToolExecutor {
1185
1771
  }
1186
1772
  async globCodeFiles(pattern, rawPath) {
1187
1773
  const target = await this.confinedPath(rawPath || ".");
1188
- const files = await this.walkFiles(target);
1774
+ let files;
1775
+ try {
1776
+ files = await this.walkFiles(target);
1777
+ }
1778
+ catch (error) {
1779
+ if (error.code !== "ENOENT")
1780
+ throw error;
1781
+ return { status: "success", paths: [], truncated: false };
1782
+ }
1189
1783
  const allPaths = files
1190
- .filter((filePath) => matchesGlob(path.relative(target, filePath).split(path.sep).join("/"), pattern))
1784
+ .filter(file => this.discoverableFile(file))
1785
+ .filter((filePath) => matchesGlob(path.relative(target, filePath).split(path.sep).join("/"), pattern) || (this.config.mode === "react" && matchesGlob(this.relativePath(filePath), pattern)))
1191
1786
  .map((filePath) => this.relativePath(filePath));
1192
1787
  return {
1193
1788
  status: "success",
@@ -1260,11 +1855,11 @@ export class CodeToolExecutor {
1260
1855
  });
1261
1856
  }
1262
1857
  async installNpmPackages(packages) {
1263
- const invalid = packages.find((packageName) => !packageNameIsSafe(packageName));
1858
+ const invalid = packages.find((packageName) => !packageNameIsSafe(packageName, this.config.mode));
1264
1859
  if (invalid) {
1265
1860
  return {
1266
1861
  status: "error",
1267
- message: `Invalid package name: ${invalid}`,
1862
+ message: `Invalid package name: ${this.config.mode === "react" ? JSON.stringify(invalid) : invalid}`,
1268
1863
  };
1269
1864
  }
1270
1865
  const args = ["install", "-S"];
@@ -1302,24 +1897,18 @@ export class CodeToolExecutor {
1302
1897
  stderr: this.virtualizeCodeOutput(result.stderr),
1303
1898
  };
1304
1899
  }
1305
- virtualizeBashOutput(value) {
1306
- if (this.workspaceContext || this.config.mode === "full-stack")
1307
- return value;
1308
- let virtualized = value;
1309
- const workspaceRoots = [...new Set([this.workspace, this.workspaceReal])]
1310
- .filter((root) => root !== path.parse(root).root)
1311
- .sort((left, right) => right.length - left.length);
1312
- for (const root of workspaceRoots) {
1313
- virtualized = virtualized.replaceAll(root, "/home/user");
1900
+ async codeBash(command, workdir) {
1901
+ const cwd = workdir.trim() || ".";
1902
+ if (path.isAbsolute(cwd) || cwd.includes("..")) {
1903
+ throw new Error(`Invalid workdir: ${cwd}. Commands run from the project root by default. Only use workdir for relative subdirectories, never absolute paths.`);
1314
1904
  }
1315
- return virtualized;
1316
- }
1317
- async fullStackBash(command, workdir) {
1318
1905
  const configuredTimeout = Number(process.env.CODE_HARNESS_BASH_TIMEOUT_SECONDS || 300);
1319
1906
  const timeoutSeconds = Number.isFinite(configuredTimeout) && configuredTimeout > 0
1320
1907
  ? configuredTimeout
1321
1908
  : 300;
1322
- const result = await this.bash(command, workdir.trim() || ".", timeoutSeconds);
1909
+ const result = await this.bash(command, cwd, timeoutSeconds);
1910
+ if (result.timed_out)
1911
+ return { status: "error", message: "Command timed out" };
1323
1912
  const exitCode = result.exit_code;
1324
1913
  const stdout = String(result.stdout || "");
1325
1914
  const stderr = String(result.stderr || "");
@@ -1454,12 +2043,6 @@ export class CodeToolExecutor {
1454
2043
  if (!(await stat(workdir)).isDirectory()) {
1455
2044
  throw new Error(`cwd is not a directory: ${cwd}`);
1456
2045
  }
1457
- // Runtime-aware callers execute commands exactly as written and report
1458
- // real local paths. The legacy /home/user translation is limited to the
1459
- // older non-Full-Stack replay contract.
1460
- const translatedCommand = this.workspaceContext || this.config.mode === "full-stack"
1461
- ? command
1462
- : command.replaceAll("/home/user", '"$ARENA_WORKSPACE"');
1463
2046
  return new Promise((resolve, reject) => {
1464
2047
  const env = {
1465
2048
  ...process.env,
@@ -1477,7 +2060,7 @@ export class CodeToolExecutor {
1477
2060
  // detached:管道成员(npm、tail…)与 bash 同进程组。只杀 bash 会留下
1478
2061
  // 孤儿持有 stdout/stderr 管道,close 永不触发,整个 turn 挂死;
1479
2062
  // 必须杀整组,并在宽限期后强制结算兜底。
1480
- const child = spawn("/bin/bash", ["-c", translatedCommand], {
2063
+ const child = spawn("/bin/bash", ["-c", command], {
1481
2064
  cwd: workdir,
1482
2065
  env,
1483
2066
  stdio: ["ignore", "pipe", "pipe"],
@@ -1525,19 +2108,14 @@ export class CodeToolExecutor {
1525
2108
  reject(this.signal.reason || new Error("operation cancelled"));
1526
2109
  return;
1527
2110
  }
1528
- // 生产契约:status 恒为 "completed"(包括非零退出码),超时无参考样本,
1529
- // 按最小猜测同样返回 completed + exit_code null,stderr 附超时说明。
1530
- if (timedOut && this.config.mode !== "full-stack") {
1531
- stderr +=
1532
- (stderr ? "\n" : "") + `command exceeded ${timeoutSeconds}s`;
1533
- }
1534
- const out = capBashOutput(this.virtualizeBashOutput(stdout));
1535
- const err = capBashOutput(this.virtualizeBashOutput(stderr));
2111
+ const out = capBashOutput(stdout);
2112
+ const err = capBashOutput(stderr);
1536
2113
  resolve({
1537
2114
  status: "completed",
1538
2115
  stderr: err.text,
1539
2116
  stdout: out.text,
1540
2117
  exit_code: timedOut ? null : code,
2118
+ timed_out: timedOut,
1541
2119
  duration_ms: Date.now() - startedAt,
1542
2120
  stderr_truncated: err.truncated,
1543
2121
  stdout_truncated: out.truncated,
@@ -1596,11 +2174,7 @@ export class CodeToolExecutor {
1596
2174
  const result = routifyWebFetchEnabled()
1597
2175
  ? await executeRoutifyWebFetch(url, format, { signal: this.signal })
1598
2176
  : await this.pageReader.readFormatted(url, format);
1599
- return {
1600
- status: "success",
1601
- title: String(result.title || "No Title Found"),
1602
- content: String(result.content || ""),
1603
- };
2177
+ return boundedWebFetchResult(result);
1604
2178
  }
1605
2179
  async pexelsRequest(pathname, parameters) {
1606
2180
  const apiKey = process.env.PEXELS_API_KEY;
@@ -1639,7 +2213,7 @@ export class CodeToolExecutor {
1639
2213
  },
1640
2214
  });
1641
2215
  }
1642
- mapPexelsPhotos(payload) {
2216
+ mapPexelsPhotos(payload, orientation) {
1643
2217
  const photos = Array.isArray(payload.photos) ? payload.photos : [];
1644
2218
  return photos.map((value) => {
1645
2219
  const photo = record(value);
@@ -1648,12 +2222,14 @@ export class CodeToolExecutor {
1648
2222
  type: "image",
1649
2223
  id: Number(photo.id),
1650
2224
  pexelsUrl: stringValue(photo.url),
1651
- recommendedUrl: stringValue(source.large2x) ||
2225
+ recommendedUrl: (orientation === "landscape" || orientation === "portrait"
2226
+ ? stringValue(source[orientation]) : "") ||
2227
+ stringValue(source.large2x) ||
1652
2228
  stringValue(source.large) ||
1653
2229
  stringValue(source.original),
1654
- thumbnailUrl: stringValue(source.medium) ||
1655
- stringValue(source.small) ||
1656
- stringValue(source.tiny),
2230
+ thumbnailUrl: stringValue(source.tiny) ||
2231
+ stringValue(source.medium) ||
2232
+ stringValue(source.small),
1657
2233
  width: Number(photo.width),
1658
2234
  height: Number(photo.height),
1659
2235
  creatorName: stringValue(photo.photographer),
@@ -1673,20 +2249,29 @@ export class CodeToolExecutor {
1673
2249
  const files = Array.isArray(video.video_files)
1674
2250
  ? video.video_files.map(record)
1675
2251
  : [];
1676
- const preferred = files.find((item) => stringValue(item.quality) === "hd") ||
1677
- files[0] ||
1678
- {};
2252
+ // Captures contain full-resolution HD/UHD files. A quality label alone
2253
+ // does not order renditions; compare their actual pixel dimensions.
2254
+ const playable = files.filter((item) => stringValue(item.file_type) === "video/mp4" && stringValue(item.link));
2255
+ const preferred = playable.sort((left, right) => (Number(right.width) || 0) * (Number(right.height) || 0) -
2256
+ (Number(left.width) || 0) * (Number(left.height) || 0))[0] || files[0] || {};
1679
2257
  return {
1680
2258
  type: "video",
1681
2259
  id: Number(video.id),
1682
2260
  pexelsUrl: stringValue(video.url),
1683
2261
  recommendedUrl: stringValue(preferred.link),
1684
- thumbnailUrl: stringValue(pictures[0]?.picture),
2262
+ thumbnailUrl: stringValue(video.image) || stringValue(pictures[0]?.picture),
1685
2263
  width: Number(video.width),
1686
2264
  height: Number(video.height),
1687
2265
  duration: Number(video.duration),
1688
2266
  creatorName: stringValue(user.name),
1689
2267
  creatorUrl: stringValue(user.url),
2268
+ videoFile: {
2269
+ url: stringValue(preferred.link),
2270
+ width: Number(preferred.width),
2271
+ height: Number(preferred.height),
2272
+ ...(preferred.quality !== undefined ? { quality: preferred.quality } : {}),
2273
+ fileType: stringValue(preferred.file_type),
2274
+ },
1690
2275
  };
1691
2276
  });
1692
2277
  }
@@ -1704,7 +2289,7 @@ export class CodeToolExecutor {
1704
2289
  if (mediaType === "image" || mediaType === "both") {
1705
2290
  const photos = await this.pexelsRequest("/v1/search", common);
1706
2291
  totalResults += Number(photos.total_results || 0);
1707
- photoResults = this.mapPexelsPhotos(photos);
2292
+ photoResults = this.mapPexelsPhotos(photos, orientation);
1708
2293
  }
1709
2294
  if (mediaType === "video" || mediaType === "both") {
1710
2295
  const videos = await this.pexelsRequest("/v1/videos/search", common);
@@ -1726,13 +2311,20 @@ export class CodeToolExecutor {
1726
2311
  }
1727
2312
  async fetchMedia(query, mediaType, count, orientation, size, locale) {
1728
2313
  const requestedCount = Math.min(10, Math.max(1, Math.trunc(count)));
1729
- return this.fetchMediaFromPexels(query, mediaType, requestedCount, orientation, size, locale);
2314
+ try {
2315
+ return await this.fetchMediaFromPexels(query, mediaType, requestedCount, orientation, size, locale);
2316
+ }
2317
+ catch (error) {
2318
+ throw new Error(`Media fetch failed: ${errorMessage(error)}`);
2319
+ }
1730
2320
  }
1731
2321
  async generateImage(rawPath, prompt, images) {
1732
2322
  if (this.generatedImageCount >= 10) {
1733
2323
  return {
1734
2324
  status: "error",
1735
- message: "generate_image can be used at most 10 times per session",
2325
+ message: this.config.mode === "react"
2326
+ ? "Failed to generate image: Image generation limit of 10 reached for this session. Use CSS gradients, SVG, or placeholder images for remaining visuals."
2327
+ : "generate_image can be used at most 10 times per session",
1736
2328
  };
1737
2329
  }
1738
2330
  this.generatedImageCount += 1;
@@ -1744,7 +2336,7 @@ export class CodeToolExecutor {
1744
2336
  mkdirSync(path.dirname(target), { recursive: true });
1745
2337
  const model = process.env.CODE_HARNESS_IMAGE_MODEL ||
1746
2338
  process.env.ARENA_IMAGE_MODEL ||
1747
- "gemini-2.5-flash-image";
2339
+ "gpt-image-2.5-sunburst";
1748
2340
  const references = [];
1749
2341
  for (const imagePath of images || []) {
1750
2342
  const source = await this.confinedPath(imagePath);