codex-overleaf-link 2.6.0 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +9 -9
  2. package/README.zh-CN.md +9 -9
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +70 -0
  5. package/extension/src/content/composerAttachments.js +35 -8
  6. package/extension/src/content/contentRuntime.js +200 -51
  7. package/extension/src/content/diagnosticsController.js +77 -34
  8. package/extension/src/content/diagnosticsExport.js +1 -1
  9. package/extension/src/content/diagnosticsPanel.js +129 -42
  10. package/extension/src/content/generated/content.bundle.js +1283 -331
  11. package/extension/src/content/generated/content.bundle.meta.json +3 -3
  12. package/extension/src/content/localSkillsPanel.js +4 -2
  13. package/extension/src/content/markdownText.js +11 -9
  14. package/extension/src/content/modelPicker.js +28 -19
  15. package/extension/src/content/nativeCompatibilityController.js +23 -9
  16. package/extension/src/content/pageBridgeClient.js +5 -5
  17. package/extension/src/content/panelMaintenance.js +4 -0
  18. package/extension/src/content/panelRenderer.js +4 -1
  19. package/extension/src/content/providerSettingsDialog.js +26 -18
  20. package/extension/src/content/recentProjects.js +26 -16
  21. package/extension/src/content/runQueueScheduler.js +19 -9
  22. package/extension/src/content/runResultActions.js +51 -4
  23. package/extension/src/content/runReviewController.js +9 -3
  24. package/extension/src/content/scopedPersistencePanelState.js +34 -25
  25. package/extension/src/content/selectionContextView.js +30 -3
  26. package/extension/src/content/sharedSessionViewSync.js +9 -1
  27. package/extension/src/content/writebackOrchestrator.js +266 -50
  28. package/extension/src/content/writingStyleSettings.js +70 -17
  29. package/extension/src/page/overleafEditor.js +12 -3
  30. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  31. package/extension/src/page/snapshotRouter.js +75 -66
  32. package/extension/src/page/treeOperations.js +200 -19
  33. package/extension/src/page/writebackRouter.js +152 -40
  34. package/extension/src/pageBridge.js +17 -7
  35. package/extension/src/shared/agentTranscript.js +23 -2
  36. package/extension/src/shared/auditRecords.js +1 -0
  37. package/extension/src/shared/compatibility.js +1 -1
  38. package/extension/src/shared/composerAttachmentDraft.js +22 -7
  39. package/extension/src/shared/failureReasons.js +15 -0
  40. package/extension/src/shared/i18n.js +16 -0
  41. package/extension/src/shared/pageRpcContract.js +1 -1
  42. package/extension/src/shared/projectFiles.js +2 -1
  43. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  44. package/extension/src/shared/staleGuard.js +58 -0
  45. package/extension/src/shared/storageMigration.js +104 -1
  46. package/extension/styles/panel.css +191 -63
  47. package/native-host/src/codexSessionRunner.js +1 -0
  48. package/native-host/src/debugLog.js +27 -3
  49. package/native-host/src/index.js +42 -11
  50. package/native-host/src/localSkills.js +1 -1
  51. package/native-host/src/mirrorWorkspace.js +47 -3
  52. package/native-host/src/nativeEnvironment.js +27 -5
  53. package/native-host/src/providerRuntime.js +1 -0
  54. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  55. package/native-host/src/taskRunnerRuntime.js +11 -3
  56. package/native-host/src/updateManager.js +24 -6
  57. package/native-host/src/writingStyleRuntime.js +1 -1
  58. package/package.json +1 -1
@@ -115,7 +115,7 @@
115
115
  }
116
116
 
117
117
  function readActiveEditorText() {
118
- return deps.readActiveEditorText?.() || '';
118
+ return deps.readActiveEditorText?.();
119
119
  }
120
120
 
121
121
  function waitForActiveEditorText(filePath, timeoutMs, options = {}) {
@@ -152,12 +152,12 @@
152
152
  };
153
153
  }
154
154
 
155
- function replaceActiveEditorText(text) {
156
- return deps.replaceActiveEditorText?.(text) || { ok: false, reason: 'Editor adapter is unavailable' };
155
+ function replaceActiveEditorText(text, options) {
156
+ return deps.replaceActiveEditorText?.(text, options) || { ok: false, reason: 'Editor adapter is unavailable' };
157
157
  }
158
158
 
159
- function replaceActiveEditorPatches(patches, nextContent) {
160
- return deps.replaceActiveEditorPatches?.(patches, nextContent) || { ok: false, reason: 'Editor adapter is unavailable' };
159
+ function replaceActiveEditorPatches(patches, nextContent, options) {
160
+ return deps.replaceActiveEditorPatches?.(patches, nextContent, options) || { ok: false, reason: 'Editor adapter is unavailable' };
161
161
  }
162
162
 
163
163
  function collectElements(selector, limit) {
@@ -266,6 +266,11 @@
266
266
  fallbackUserMessage: 'The file changed while Codex was working.',
267
267
  fallbackNextAction: 'Review the current file, then rerun the task.'
268
268
  },
269
+ document_identity_mismatch: {
270
+ stage: 'preflight', severity: 'blocked', defaultRetryable: true,
271
+ fallbackUserMessage: 'Codex could not confirm that the editor content belongs to the target file, so it was not used.',
272
+ fallbackNextAction: 'Wait for the target file to open and settle, then retry. Reload Overleaf if this continues.'
273
+ },
269
274
  patch_anchor_not_found: {
270
275
  stage: 'preflight', severity: 'blocked', defaultRetryable: true,
271
276
  fallbackUserMessage: 'The edit anchor no longer matches current Overleaf content.',
@@ -451,6 +456,10 @@
451
456
  const trackedChangeCaptures = [];
452
457
  const safeBaseFiles = normalizeBaseFilesForSafety(options.baseFiles);
453
458
  const baseFileLookup = window.CodexOverleafStaleGuard?.buildBaseFileLookup(safeBaseFiles);
459
+ // Binary and unread entries are not independent empty-text references.
460
+ const provenanceReferences = new Map((Array.isArray(safeBaseFiles) ? safeBaseFiles : [])
461
+ .filter(file => file.kind !== 'binary' && typeof file.content === 'string')
462
+ .map(file => [file.path, file.content]));
454
463
  const baseBinaryFileLookup = buildBaseBinaryFileLookup(safeBaseFiles);
455
464
  // Recheck project identity between awaited operations and before each write.
456
465
  const runProjectId = typeof options.runProjectId === 'string' ? options.runProjectId : '';
@@ -490,8 +499,16 @@
490
499
  const deadlineFailure = () => Number.isFinite(options.deadlineAt) && now() >= options.deadlineAt
491
500
  ? { ok: false, code: 'writeback_deadline_exceeded', changedDocument: false,
492
501
  reason: 'The undo time budget expired before this operation could start.' } : null;
493
- const editOptions = () => ({ baseFileLookup, runProjectId, deadlineAt: options.deadlineAt,
502
+ const editOptions = () => ({ baseFileLookup, provenanceReferences, runProjectId, deadlineAt: options.deadlineAt,
494
503
  reviewGuard: options.reviewGuard, reviewAwait: options.reviewAwait,
504
+ recheckEditDispatch: () => {
505
+ if (readWriteCancellationSequence() !== cancelBaselineSequence) return cancelledSkipResult;
506
+ if (!writeGuardSurface) return { ok: false, code: 'editor_project_id_unavailable', changedDocument: false };
507
+ const editorProjectId = writeGuardSurface.getEditorProjectIdPageSide(runProjectId);
508
+ const code = !runProjectId || !editorProjectId ? 'editor_project_id_unavailable'
509
+ : editorProjectId !== runProjectId ? 'aborted_project_changed' : '';
510
+ return code ? writeGuardSurface.abortDispatchResult(code, runProjectId || null, editorProjectId).skipped[0].result : null;
511
+ },
495
512
  recheckWriteProject: writeGuardSurface ? () => {
496
513
  const expired = options.reviewGuard?.() || deadlineFailure();
497
514
  return expired ? { applied: [], skipped: [{ operation: {}, result: expired }] }
@@ -611,11 +628,54 @@
611
628
  const isUntouchedOpenFailure = result => result?.ok === false && result.failure?.code === 'target_file_open_failed'
612
629
  && result.failure.changedDocument === false;
613
630
 
631
+ function documentIdentityContext(filePath, projectId = treeOperations.getProjectId?.()) {
632
+ const target = (deps.resolveTargetDocument || treeOperations.resolveTargetDocument)?.(filePath, { projectId });
633
+ return { projectId, targetDocumentId: target?.status === 'known' ? target.documentId : '' };
634
+ }
635
+
636
+ function documentProvenance(filePath, text, references, context = documentIdentityContext(filePath)) {
637
+ const capture = (deps.captureEditorDocument || treeOperations.captureEditorDocument)?.({ path: filePath, projectId: context.projectId });
638
+ const target = (deps.resolveTargetDocument || treeOperations.resolveTargetDocument)?.(filePath, { projectId: context.projectId });
639
+ if ((target?.status === 'known' && context.targetDocumentId && target.documentId !== context.targetDocumentId)
640
+ || (context.projectId && treeOperations.getProjectId?.() !== context.projectId))
641
+ return { ok: false, code: 'document_identity_mismatch', identityState: 'mismatch' };
642
+ if (capture?.identityState === 'mismatch' || capture?.reason === 'navigation_conflict') return capture;
643
+ if (capture?.ok) {
644
+ const id = capture.documentIdentity.documentId;
645
+ if (context.targetDocumentId && context.targetDocumentId !== id)
646
+ return { ok: false, code: 'document_identity_mismatch', identityState: 'mismatch' };
647
+ context.targetDocumentId = id;
648
+ return { ok: true, identityState: 'match', capture };
649
+ }
650
+ if (typeof text !== 'string') return { ok: false, code: 'document_identity_mismatch', identityState: 'fallback' };
651
+ return window.CodexOverleafStaleGuard?.assessDocumentProvenance?.({ path: filePath, text, references })
652
+ || { ok: true, identityState: 'fallback' };
653
+ }
654
+
655
+ async function settleDocumentProvenance(filePath, references, context, deadlineAt = Infinity) {
656
+ const deadline = Math.min(deadlineAt, Date.now() + TreeOperations.ACTIVE_EDITOR_STABILITY_MS);
657
+ let result;
658
+ do {
659
+ result = documentProvenance(filePath, readActiveEditorText(), references, context);
660
+ if (result.identityState === 'match' || result.identityState === 'mismatch' || Date.now() >= deadline) return result;
661
+ await delay(Math.min(50, deadline - Date.now()));
662
+ } while (Date.now() <= deadline);
663
+ return result;
664
+ }
665
+
666
+ function documentMismatchResult(filePath, provenance) {
667
+ const failure = buildPageFailure('document_identity_mismatch', { file: filePath,
668
+ operationType: 'edit', changedDocument: false,
669
+ evidence: { matchedPath: provenance.matchedPath, writeStarted: false } });
670
+ return { ...provenance, reason: failure.userMessage, failure };
671
+ }
672
+
614
673
  async function applyEditOperation(operation, options = {}) {
615
674
  const reviewBlock = options.reviewGuard?.();
616
675
  if (reviewBlock) return reviewBlock;
617
676
  const currentPath = getActiveFilePath();
618
- const editorReady = await awaitReview(options, () => ensureEditorReadyForOperation(operation, options.baseFileLookup, currentPath));
677
+ const identityContext = documentIdentityContext(operation.path, options.runProjectId);
678
+ const editorReady = await awaitReview(options, () => ensureEditorReadyForOperation(operation, options.baseFileLookup, currentPath, options.provenanceReferences, identityContext, options.deadlineAt));
619
679
  if (!editorReady.ok) {
620
680
  return editorReady;
621
681
  }
@@ -639,7 +699,7 @@
639
699
  options.baseFileLookup
640
700
  ) || { ok: true };
641
701
  if (!freshness.ok) {
642
- const ready = await awaitReview(options, () => waitForFreshEditorTextForOperation(operation, options.baseFileLookup, 1500));
702
+ const ready = await awaitReview(options, () => waitForFreshEditorTextForOperation(operation, options.baseFileLookup, 1500, identityContext, options.provenanceReferences));
643
703
  if (options.reviewGuard?.()) return options.reviewGuard();
644
704
  if (!ready.ok) {
645
705
  const decorated = attachWritebackDebug(freshness, 'stale_guard', operation, options.baseFileLookup, current, {
@@ -728,6 +788,7 @@
728
788
  };
729
789
  }
730
790
 
791
+ await awaitReview(options, () => settleDocumentProvenance(operation.path, options.provenanceReferences, identityContext, options.deadlineAt));
731
792
  if (options.noTraceUndo === true) {
732
793
  const blocked = typeof options.recheckWriteProject === 'function'
733
794
  ? await options.recheckWriteProject()
@@ -743,24 +804,40 @@
743
804
 
744
805
  const finalReviewBlock = options.reviewGuard?.();
745
806
  if (finalReviewBlock) return finalReviewBlock;
807
+ if (Number.isFinite(options.deadlineAt) && now() >= options.deadlineAt)
808
+ return { ok: false, code: 'writeback_deadline_exceeded', changedDocument: false };
809
+ if (options.noTraceUndo !== true) {
810
+ const dispatchBlock = options.recheckEditDispatch?.();
811
+ if (dispatchBlock) return dispatchBlock;
812
+ const reviewing = getReviewingState({});
813
+ if (trackReviewingChanges && !isReviewingConfirmedForWrite(reviewing))
814
+ return buildReviewingRequiredBlockedResult([operation], reviewing).skipped[0].result;
815
+ if (!trackReviewingChanges && !isEditingConfirmedForNoTraceUndo(reviewing))
816
+ return buildEditingRequiredBlockedResult([operation], reviewing).skipped[0].result;
817
+ }
746
818
  // Read and dispatch synchronously against the same document. Navigation,
747
819
  // freshness polling and Undo mode/project checks can all have awaited.
748
- if (getActiveFilePath() !== operation.path || readActiveEditorText() !== current) {
820
+ const observedText = readActiveEditorText();
821
+ const provenance = documentProvenance(operation.path, observedText, options.provenanceReferences, identityContext);
822
+ const dispatchText = provenance.capture?.text ?? observedText;
823
+ if (!provenance.ok) return documentMismatchResult(operation.path, provenance);
824
+ if (getActiveFilePath() !== operation.path || dispatchText !== current) {
749
825
  const reason = 'The editor changed before the edit could be applied; the document was not overwritten.';
750
826
  return { ok: false, code: 'stale_source_changed', reason,
751
827
  failure: buildPageFailure('stale_source_changed', { file: operation.path,
752
828
  operationType: options.noTraceUndo ? 'undo' : 'edit', changedDocument: false, userMessage: reason }) };
753
829
  }
754
830
  const result = Array.isArray(operation.patches) && operation.patches.length
755
- ? replaceActiveEditorPatches(preparedPatches.patches, nextContent)
756
- : replaceActiveEditorText(nextContent);
831
+ ? replaceActiveEditorPatches(preparedPatches.patches, nextContent, { expectedCapture: provenance.capture })
832
+ : replaceActiveEditorText(nextContent, { expectedCapture: provenance.capture });
757
833
  if (!result.ok) {
758
834
  return result;
759
835
  }
760
836
 
761
837
  const verifyWaitMs = Math.min(deps.writeVerifyWaitMs ?? 2500, Number.isFinite(options.deadlineAt)
762
838
  ? Math.max(0, options.deadlineAt - now()) : Infinity);
763
- const verified = await verifyActiveEditorText(nextContent, operation.path, verifyWaitMs, current);
839
+ const verified = await verifyActiveEditorText(nextContent, operation.path, verifyWaitMs, current,
840
+ { ...identityContext, requireIdentity: provenance.identityState === 'match', references: options.provenanceReferences });
764
841
  if (!verified.ok) {
765
842
  const decorated = attachWritebackDebug(verified, 'write_verification', operation, options.baseFileLookup, readActiveEditorText(), {
766
843
  initialActivePath: currentPath,
@@ -849,17 +926,19 @@
849
926
  };
850
927
  }
851
928
 
852
- async function verifyActiveEditorText(expected, filePath, waitMs = 1000, before) {
929
+ async function verifyActiveEditorText(expected, filePath, waitMs = 1000, before, identityContext = documentIdentityContext(filePath)) {
853
930
  const deadline = Date.now() + waitMs;
854
- let actual = readActiveEditorText();
855
- while (actual !== expected && Date.now() < deadline) {
856
- await delay(50);
931
+ const fallbackAt = Math.min(deadline, Date.now() + TreeOperations.ACTIVE_EDITOR_STABILITY_MS);
932
+ let actual, provenance, identityConfirmed = false;
933
+ while (true) {
857
934
  actual = readActiveEditorText();
858
- }
859
- if (actual === expected) {
860
- return {
861
- ok: true
862
- };
935
+ provenance = documentProvenance(filePath, actual, identityContext.references, identityContext);
936
+ actual = provenance.capture?.text ?? actual;
937
+ identityConfirmed = provenance.ok && getActiveFilePath() === filePath
938
+ && (provenance.identityState === 'match' || (!identityContext.requireIdentity && Date.now() >= fallbackAt));
939
+ if (identityConfirmed && actual === expected) return { ok: true };
940
+ if (Date.now() >= deadline) break;
941
+ await delay(Math.min(50, deadline - Date.now()));
863
942
  }
864
943
  return {
865
944
  ok: false,
@@ -867,7 +946,8 @@
867
946
  reason: `${filePath || '当前文件'} 写入后读回内容和 Codex 预期不一致,已停止把这次操作标记为成功。请刷新 Overleaf 后重试。`,
868
947
  expectedLength: String(expected || '').length,
869
948
  actualLength: String(actual || '').length,
870
- evidence: describeReadbackMismatch(expected, actual, before, filePath)
949
+ evidence: { ...describeReadbackMismatch(expected, actual, before, filePath),
950
+ activeMatchesTarget: identityConfirmed, identityState: provenance.identityState }
871
951
  };
872
952
  }
873
953
 
@@ -897,7 +977,8 @@
897
977
  return hash.toString(16).padStart(8, '0');
898
978
  }
899
979
 
900
- async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath) {
980
+ async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath, provenanceReferences,
981
+ identityContext = documentIdentityContext(operation.path), deadlineAt = Infinity) {
901
982
  if (!operation?.path) {
902
983
  return {
903
984
  ok: true,
@@ -912,6 +993,7 @@
912
993
  const currentText = readActiveEditorText();
913
994
  const previousEditorIdentity = getActiveEditorIdentity();
914
995
  const previousSignature = contentSignature(currentText);
996
+ const initialProvenance = documentProvenance(filePath, currentText, provenanceReferences, identityContext);
915
997
 
916
998
  const opened = await openFileByPath(filePath, { force: initialActivePath === filePath });
917
999
  if (!opened.ok) {
@@ -956,7 +1038,11 @@
956
1038
  }
957
1039
 
958
1040
  return waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, 9000, {
1041
+ identityContext,
1042
+ deadlineAt,
959
1043
  initialActivePath,
1044
+ initialProvenance,
1045
+ provenanceReferences,
960
1046
  previousSignature,
961
1047
  forceSettledOpen: true,
962
1048
  openedMethod: opened.method || ''
@@ -964,11 +1050,13 @@
964
1050
  }
965
1051
 
966
1052
  async function waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, timeoutMs, options = {}) {
967
- const deadline = Date.now() + timeoutMs;
1053
+ const deadline = Math.min(options.deadlineAt ?? Infinity, Date.now() + timeoutMs);
968
1054
  const startedAt = Date.now();
1055
+ const identityDeadline = Math.min(deadline, startedAt + Math.min(1500, writebackOpenSettleMs || TreeOperations.ACTIVE_EDITOR_STABILITY_MS));
969
1056
  const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
970
1057
  let lastText = readActiveEditorText();
971
1058
  let lastActivePath = getActiveFilePath();
1059
+ let mismatch = null;
972
1060
  while (Date.now() < deadline) {
973
1061
  lastActivePath = getActiveFilePath();
974
1062
  lastText = readActiveEditorText();
@@ -977,21 +1065,28 @@
977
1065
  const settledLongEnough = Date.now() - startedAt >= minSettleMs;
978
1066
  const contentChangedFromPrevious = !options.previousSignature
979
1067
  || contentSignature(lastText) !== options.previousSignature;
1068
+ const provenance = documentProvenance(filePath, lastText, options.provenanceReferences, options.identityContext);
1069
+ lastText = provenance.capture?.text ?? lastText;
1070
+ mismatch = provenance.ok ? null : provenance;
980
1071
  // Confirm navigation independently of source freshness. A stale target
981
1072
  // can be correctly open; identical baseline bytes alone cannot prove a
982
1073
  // switch. Content movement remains the fallback for editor lifecycles
983
1074
  // whose document identity cannot be observed reliably.
984
- const switchConfirmed = identityChanged
985
- || options.initialActivePath === filePath
1075
+ const switchConfirmed = provenance.identityState === 'match' || identityChanged
1076
+ || (options.initialActivePath === filePath && Boolean(previousEditorIdentity)
1077
+ && options.initialProvenance?.ok === true && provenance.ok)
986
1078
  || (contentChangedFromPrevious && settledLongEnough);
987
- if (activeFileMatches && switchConfirmed && settledLongEnough) {
1079
+ if (activeFileMatches && switchConfirmed && settledLongEnough && provenance.ok
1080
+ && (provenance.identityState === 'match' || Date.now() >= identityDeadline)) {
988
1081
  return {
989
1082
  ok: true,
990
- text: lastText
1083
+ text: lastText,
1084
+ capture: provenance.capture
991
1085
  };
992
1086
  }
993
- await delay(100);
1087
+ await delay(Math.max(0, Math.min(100, deadline - Date.now())));
994
1088
  }
1089
+ if (mismatch) return documentMismatchResult(filePath, mismatch);
995
1090
  // §9.1: if the active file ended up on a different document, this is
996
1091
  // target_file_not_active; if the file did become active but the editor
997
1092
  // document never settled, this is target_editor_not_ready.
@@ -1036,21 +1131,30 @@
1036
1131
  const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
1037
1132
  let lastText = readActiveEditorText();
1038
1133
  let lastActivePath = getActiveFilePath();
1134
+ const identityContext = options.identityContext || documentIdentityContext(filePath);
1135
+ let mismatch = null;
1039
1136
  while (Date.now() < deadline) {
1040
1137
  lastActivePath = getActiveFilePath();
1041
1138
  lastText = readActiveEditorText();
1139
+ const provenance = documentProvenance(filePath, lastText, options.references, identityContext);
1140
+ lastText = provenance.capture?.text ?? lastText;
1141
+ mismatch = provenance.ok ? null : provenance;
1042
1142
  const activeFileMatches = lastActivePath === filePath;
1043
1143
  const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
1044
1144
  const settledLongEnough = Date.now() - startedAt >= minSettleMs;
1045
1145
  const sameFileReopenSettled = options.initialActivePath === filePath && settledLongEnough;
1046
- if (activeFileMatches && settledLongEnough && (identityChanged || sameFileReopenSettled)) {
1146
+ if (activeFileMatches && settledLongEnough && provenance.ok
1147
+ && (provenance.identityState === 'match' || Date.now() - startedAt >= TreeOperations.ACTIVE_EDITOR_STABILITY_MS)
1148
+ && (provenance.identityState === 'match' || identityChanged || sameFileReopenSettled)) {
1047
1149
  return {
1048
1150
  ok: true,
1049
- text: lastText
1151
+ text: lastText,
1152
+ capture: provenance.capture
1050
1153
  };
1051
1154
  }
1052
- await delay(100);
1155
+ await delay(Math.max(0, Math.min(100, deadline - Date.now())));
1053
1156
  }
1157
+ if (mismatch) return documentMismatchResult(filePath, mismatch);
1054
1158
  return {
1055
1159
  ok: false,
1056
1160
  code: 'editor_document_not_switched',
@@ -1065,24 +1169,27 @@
1065
1169
  return normalized.trim().length === 0 ? '' : normalized;
1066
1170
  }
1067
1171
 
1068
- async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500) {
1172
+ async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500, identityContext, references) {
1069
1173
  const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
1070
1174
  while (Date.now() < deadline) {
1071
1175
  if (operation?.path && getActiveFilePath() !== operation.path) {
1072
1176
  await delay(60);
1073
1177
  continue;
1074
1178
  }
1075
- const text = readActiveEditorText();
1179
+ const observed = readActiveEditorText();
1180
+ const provenance = documentProvenance(operation.path, observed, references, identityContext);
1181
+ const text = provenance.capture?.text ?? observed;
1076
1182
  const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
1077
1183
  operation,
1078
1184
  text,
1079
1185
  baseFileLookup
1080
1186
  ) || { ok: true };
1081
- if (freshness.ok) {
1187
+ if (freshness.ok && provenance.ok) {
1082
1188
  return {
1083
1189
  ok: true,
1084
1190
  text,
1085
- freshness
1191
+ freshness,
1192
+ capture: provenance.capture
1086
1193
  };
1087
1194
  }
1088
1195
  await delay(60);
@@ -1431,6 +1538,7 @@
1431
1538
  const expected = options.baseFileLookup?.get(operation.path);
1432
1539
  const text = proof?.v === 1 && proof.kind === 'text' && typeof expected === 'string';
1433
1540
  const binary = proof?.v === 1 && proof.kind === 'binary' && /^[a-f0-9]{64}$/i.test(proof.sha256 || '');
1541
+ const identityContext = documentIdentityContext(operation.path, projectId);
1434
1542
  const isCurrent = () => Boolean(projectId) && projectId === options.runProjectId
1435
1543
  && treeOperations.getProjectId?.() === projectId && readWriteCancellationSequence() === cancellation && !options.reviewGuard?.();
1436
1544
  if (!isCurrent() || (!text && !binary) || typeof deps.deleteTextFile !== 'function') {
@@ -1438,7 +1546,7 @@
1438
1546
  reason: 'Verified created-file deletion is unavailable for this run.' };
1439
1547
  }
1440
1548
  if (text && projectPathExists(operation.path)) {
1441
- const current = await readCurrentTextFileForFreshness(operation.path);
1549
+ const current = await readCurrentTextFileForFreshness(operation.path, options.baseFileLookup);
1442
1550
  if (!current.ok || current.text !== expected || !isCurrent()) {
1443
1551
  return { ok: false, code: 'undo_operation_failed', changedDocument: false,
1444
1552
  reason: 'The created file changed after this run and was left untouched.' };
@@ -1447,9 +1555,10 @@
1447
1555
  const result = await deps.deleteTextFile(operation, {
1448
1556
  isCurrent, undoCreatedFile: true, deadlineAt: options.deadlineAt,
1449
1557
  expectedContent: text ? expected : undefined,
1450
- prepareTextPreimage: text ? () => readCurrentTextFileForFreshness(operation.path) : undefined,
1558
+ prepareTextPreimage: text ? () => readCurrentTextFileForFreshness(operation.path, options.baseFileLookup) : undefined,
1451
1559
  expectedSha256: binary ? proof.sha256.toLowerCase() : undefined,
1452
1560
  canDelete: () => isCurrent() && (binary || (treeOperations.getActiveFilePath?.() === operation.path
1561
+ && documentProvenance(operation.path, readActiveEditorText(), options.baseFileLookup, identityContext).ok
1453
1562
  && readActiveEditorText() === expected))
1454
1563
  });
1455
1564
  if (!result.ok) return result;
@@ -1552,7 +1661,7 @@
1552
1661
  };
1553
1662
  }
1554
1663
 
1555
- const current = await readCurrentTextFileForFreshness(operation.path);
1664
+ const current = await readCurrentTextFileForFreshness(operation.path, baseFileLookup);
1556
1665
  if (!current.ok) {
1557
1666
  return current;
1558
1667
  }
@@ -1579,13 +1688,14 @@
1579
1688
  return { ok: true };
1580
1689
  }
1581
1690
 
1582
- async function readCurrentTextFileForFreshness(filePath) {
1691
+ async function readCurrentTextFileForFreshness(filePath, references) {
1583
1692
  const normalizedPath = normalizeSafeProjectPath(filePath);
1584
1693
  if (!normalizedPath) {
1585
1694
  return invalidProjectPathResult('freshness path');
1586
1695
  }
1587
1696
  const currentPath = getActiveFilePath();
1588
1697
  const previousEditorIdentity = getActiveEditorIdentity();
1698
+ const identityContext = documentIdentityContext(normalizedPath);
1589
1699
  const opened = await openFileByPath(normalizedPath, { force: true });
1590
1700
  if (!opened.ok) {
1591
1701
  return {
@@ -1595,6 +1705,8 @@
1595
1705
  };
1596
1706
  }
1597
1707
  const ready = await waitForEditorDocumentForPath(normalizedPath, previousEditorIdentity, 9000, {
1708
+ identityContext,
1709
+ references,
1598
1710
  initialActivePath: currentPath,
1599
1711
  forceSettledOpen: true
1600
1712
  });
@@ -58,6 +58,8 @@
58
58
  document,
59
59
  getActiveFilePathFromEditorStore,
60
60
  getActiveEditorIdentity,
61
+ getCodeMirrorEditorView,
62
+ getCodeMirrorEditorViews: () => codeMirrorEditorViewCandidates().map(([, view]) => view).filter(isCodeMirrorEditorView),
61
63
  normalizePath: normalizeSafeProjectPath,
62
64
  readActiveEditorText,
63
65
  window
@@ -97,6 +99,11 @@
97
99
  projectSnapshotBridge = window.CodexOverleafProjectSnapshot.create({
98
100
  buildProjectFileList: snapshotRouter.buildProjectFileList,
99
101
  buildProjectSnapshot: snapshotRouter.buildProjectSnapshot,
102
+ getSnapshotEditorCaptures: snapshotRouter.getSnapshotEditorCaptures,
103
+ captureEditorDocument: treeOperations.captureEditorDocument,
104
+ isCaptureCurrent: treeOperations.isCaptureCurrent,
105
+ getActiveFilePath,
106
+ getActiveEditorIdentity,
100
107
  getProjectId,
101
108
  normalizePath: normalizeSafeProjectPath,
102
109
  window
@@ -1675,21 +1682,21 @@
1675
1682
  return editorAdapter.readActiveEditorText();
1676
1683
  }
1677
1684
 
1678
- function replaceActiveEditorText(text) {
1679
- return editorAdapter.replaceActiveEditorText(text);
1685
+ function replaceActiveEditorText(text, options) {
1686
+ return editorAdapter.replaceActiveEditorText(text, options);
1680
1687
  }
1681
1688
 
1682
- function replaceActiveEditorPatches(patches, nextContent) {
1683
- return editorAdapter.replaceActiveEditorPatches(patches, nextContent);
1689
+ function replaceActiveEditorPatches(patches, nextContent, options) {
1690
+ return editorAdapter.replaceActiveEditorPatches(patches, nextContent, options);
1684
1691
  }
1685
1692
 
1686
1693
  function getCodeMirrorEditorView() {
1687
1694
  return findCodeMirrorEditorView()?.view || null;
1688
1695
  }
1689
1696
 
1690
- function findCodeMirrorEditorView() {
1697
+ function codeMirrorEditorViewCandidates() {
1691
1698
  const store = getOverleafUnstableStore();
1692
- const candidates = [
1699
+ return [
1693
1700
  ['store:editor.view', readStoreValue(store, 'editor.view')],
1694
1701
  ['store:editor.view.camel', readStoreValue(store, 'editor.view.camel')],
1695
1702
  ['store:editor', readStoreValue(store, 'editor').view],
@@ -1698,7 +1705,10 @@
1698
1705
  ['window._ide.editorView', window._ide?.editorView]
1699
1706
  ];
1700
1707
 
1701
- for (const [source, candidate] of candidates) {
1708
+ }
1709
+
1710
+ function findCodeMirrorEditorView() {
1711
+ for (const [source, candidate] of codeMirrorEditorViewCandidates()) {
1702
1712
  if (isCodeMirrorEditorView(candidate)) {
1703
1713
  return {
1704
1714
  view: candidate,
@@ -166,6 +166,9 @@
166
166
  "timeout": {
167
167
  "message": "timed out"
168
168
  },
169
+ "native_request_timeout": {
170
+ "code": "native_request_timeout"
171
+ },
169
172
  "output_limit": {
170
173
  "message": "output limit exceeded"
171
174
  },
@@ -182,7 +185,7 @@
182
185
  "message": "unsupported_parameter reasoning.summary"
183
186
  },
184
187
  "storage_quota": {
185
- "message": "quota"
188
+ "message": "storage quota"
186
189
  },
187
190
  "checkpoint": {
188
191
  "message": "checkpoint"
@@ -220,6 +223,9 @@
220
223
  "provider_rate_limited": {
221
224
  "message": "provider_rate_limited"
222
225
  },
226
+ "provider_quota_exceeded": {
227
+ "message": "provider_quota_exceeded"
228
+ },
223
229
  "provider_connection_timeout": {
224
230
  "message": "provider_connection_timeout"
225
231
  },
@@ -865,6 +871,14 @@
865
871
  nextEn: 'Switch between Responses and Chat Completions, or test again with Auto detection.',
866
872
  code: 'provider_protocol_incompatible'
867
873
  },
874
+ {
875
+ pattern: /provider_quota_exceeded|insufficient_quota|exceeded your current quota|(?:billing|usage|spend(?:ing)?|credit)[_ -]?(?:hard[_ -]?)?limit[_ -]?(?:reached|exceeded)|(?:billing|plan).{0,40}(?:quota|limit|exhausted)|(?:quota|credits?).{0,40}(?:billing|plan|exhausted|insufficient)/i,
876
+ conclusionZh: '模型服务的配额或账单限额已达到。',
877
+ conclusionEn: 'The model provider quota or billing limit was reached.',
878
+ nextZh: '请检查模型服务账户和套餐,或切换模型或模型服务后重试。',
879
+ nextEn: 'Check the provider account and plan, or switch model or provider before retrying.',
880
+ code: 'provider_quota_exceeded'
881
+ },
868
882
  {
869
883
  pattern: /provider_rate_limited|rate limit was reached/i,
870
884
  conclusionZh: '这轮没有完成:第三方模型服务触发了限流。',
@@ -951,6 +965,13 @@
951
965
  // failure data alongside the human-readable text. The string text is kept
952
966
  // verbatim because it has mode-aware nuance (e.g. "No files were written"
953
967
  // vs "This run did not start") the catalog's fallback strings don't capture.
968
+ if (context.code === 'native_request_timeout' || /native_request_timeout/i.test(text)) {
969
+ return {
970
+ conclusion: textFor(locale, '本机服务没有响应。', 'The local service did not respond.'),
971
+ nextStep: textFor(locale, '请重试,或打开「诊断」检查本机连接。', 'Retry, or open Diagnostics to check the local connection.'),
972
+ failureCode: 'native_request_timeout'
973
+ };
974
+ }
954
975
  if (context.code === 'codex_local_skill_unavailable' || /(?:The )?selected local Codex skill is unavailable/i.test(text)) {
955
976
  return {
956
977
  conclusion: textFor(locale, '所选 Codex 本地技能不可用或已关闭,本轮没有启动。', 'The selected local Codex skill is unavailable or disabled. This run did not start.'),
@@ -1044,7 +1065,7 @@
1044
1065
  failureCode: 'native_protocol_incompatible'
1045
1066
  };
1046
1067
  }
1047
- if (/quota|kQuotaBytes|QUOTA_BYTES/i.test(text)) {
1068
+ if (/kQuotaBytes|QUOTA_BYTES|QuotaExceededError|storage quota/i.test(text)) {
1048
1069
  return {
1049
1070
  conclusion: textFor(locale, 'Codex 结果已经生成,但本地会话记录超出 Chrome 存储配额。', 'Codex produced a result, but local session history exceeded Chrome storage quota.'),
1050
1071
  nextStep: textFor(locale, '请删除一些旧 session,或刷新扩展后重试;这不是论文分析本身失败。', 'Delete older sessions or reload the extension and retry. The paper analysis itself did not fail.'),
@@ -261,6 +261,7 @@
261
261
  }
262
262
 
263
263
  function summarizeMirror(mirror = {}) {
264
+ mirror = mirror ?? { status: 'unavailable' };
264
265
  const files = Array.isArray(mirror.files) ? mirror.files : [];
265
266
  const skippedFiles = Array.isArray(mirror.skippedFiles) ? mirror.skippedFiles : [];
266
267
  return removeEmptySummaryFields({
@@ -12,7 +12,7 @@
12
12
  const MIN_NATIVE_VERSION = '1.0.0';
13
13
  const MIN_COMPATIBLE_NATIVE_VERSION = '1.0.0';
14
14
  const MIN_COMPATIBLE_EXTENSION_VERSION = '1.0.0';
15
- const BUILD_TARGET_VERSION = '2.6.0';
15
+ const BUILD_TARGET_VERSION = '2.6.5';
16
16
  const DEFAULT_CHROME_EXTENSION_ID = 'illdpneeeopfffmiepaejglgmhpmdhdc';
17
17
  const REQUIRED_CAPABILITIES = Object.freeze([
18
18
  'bridgePing',
@@ -5,6 +5,13 @@
5
5
  'use strict';
6
6
  const EPOCH_KEY = 'composer-attachment-draft:clear-epoch';
7
7
  const normalize = value => SessionState.normalizeComposerAttachmentsForPersistence(value);
8
+ function normalizeOmitted(value) {
9
+ const names = (Array.isArray(value?.names) ? value.names : [])
10
+ .filter(name => typeof name === 'string')
11
+ .map(name => name.replace(/\0/g, '').trim().slice(0, 180))
12
+ .filter(Boolean).slice(0, 8);
13
+ return { count: names.length, names };
14
+ }
8
15
  const scopeKey = scope => JSON.stringify([scope?.accountScopeId || '', scope?.projectId || '']);
9
16
  function checkedScope(scope) {
10
17
  const accountScopeId = String(scope?.accountScopeId || '').trim();
@@ -42,25 +49,28 @@
42
49
  throw failure('account_scope_unavailable');
43
50
  }
44
51
  const revision = Number(previous?.revision) || 0;
45
- result = { epoch, clearedAt, revision, attachments: normalize(previous?.attachments) };
52
+ result = { epoch, clearedAt, revision, attachments: normalize(previous?.attachments), omittedAttachments: normalizeOmitted(previous?.omittedAttachments) };
46
53
  if (!value) return;
47
54
  const attachments = normalize(value.attachments);
55
+ const omittedAttachments = normalizeOmitted(value.omittedAttachments);
56
+ const hasDraft = attachments.length || omittedAttachments.count;
48
57
  if (value.epoch !== epoch) {
49
58
  // A clear performed by this page also invalidates its old base.
50
59
  // Its empty draft needs no write; an old nonempty draft cannot return.
51
- if (!attachments.length) return;
60
+ if (!hasDraft) return;
52
61
  throw failure('composer_draft_cleared');
53
62
  }
54
63
  if (value.revision !== revision) {
55
64
  // Writing what another tab already stored changes nothing (for example
56
65
  // both tabs clearing an empty draft on send); only a real edit conflicts.
57
- if (JSON.stringify(attachments) === JSON.stringify(result.attachments)) return;
66
+ if (JSON.stringify(attachments) === JSON.stringify(result.attachments)
67
+ && JSON.stringify(omittedAttachments) === JSON.stringify(result.omittedAttachments)) return;
58
68
  throw failure('composer_draft_conflict');
59
69
  }
60
- if (attachments.length && value.unbased && clearedAt && value.changedAt <= clearedAt) {
70
+ if (hasDraft && value.unbased && clearedAt && value.changedAt <= clearedAt) {
61
71
  throw failure('composer_draft_cleared');
62
72
  }
63
- result = { epoch, clearedAt, revision: revision + 1, attachments };
73
+ result = { epoch, clearedAt, revision: revision + 1, attachments, omittedAttachments };
64
74
  store.put({ id, type: 'composer-attachment-draft', ...scope, ...result });
65
75
  } catch (caught) { error = caught; tx.abort(); }
66
76
  };
@@ -120,7 +130,11 @@
120
130
  syncScope();
121
131
  if (hydrated) return;
122
132
  // Loading is allowed to finish after a local add/remove. Local edits win.
123
- if (!changedVersion && !options.getAttachments().length) options.restore(normalize(attachments));
133
+ if (!changedVersion && !options.getAttachments().length) {
134
+ options.restore(normalize(attachments));
135
+ const omitted = normalizeOmitted(base?.omittedAttachments);
136
+ if (omitted.count) options.onRestoreOmittedAttachments?.(omitted);
137
+ }
124
138
  hydrated = true; publish();
125
139
  }
126
140
  function changed() {
@@ -138,10 +152,11 @@
138
152
  if (captured !== generation || scopeKey(options.getScope()) !== key || changedVersion === savedVersion) return;
139
153
  const version = changedVersion;
140
154
  const attachments = normalize(options.getAttachments());
155
+ const omittedAttachments = normalizeOmitted({ names: options.getOmittedAttachmentNames?.() });
141
156
  const unbased = dirtyUnbased, changedAt = dirtySince;
142
157
  if (!base) await load(scope);
143
158
  if (captured !== generation || scopeKey(options.getScope()) !== key) return;
144
- const written = await options.write(checkedScope(scope), { ...base, attachments, unbased, changedAt });
159
+ const written = await options.write(checkedScope(scope), { ...base, attachments, omittedAttachments, unbased, changedAt });
145
160
  if (captured !== generation) return;
146
161
  base = written; savedVersion = version;
147
162
  if (changedVersion === version) { dirtySince = 0; dirtyUnbased = false; }