codex-overleaf-link 2.5.1 → 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 (111) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +148 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -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.',
@@ -367,12 +372,12 @@
367
372
  const trackReviewingChanges = options.requireReviewing === true && hasOperations;
368
373
  let reviewingPolicy = null;
369
374
  if (options.requireReviewing === true && hasOperations) {
370
- const reviewing = await ensureReviewing({ waitMs: 1800 });
375
+ const reviewing = await ensureReviewing({ waitMs: 1800, runProjectId: options.runProjectId });
371
376
  if (!reviewing.ok) {
372
377
  return buildReviewingRequiredBlockedResult(operations, reviewing);
373
378
  }
374
379
  } else if (options.requireEditing === true && hasOperations) {
375
- const editing = await ensureEditing({ waitMs: 1800 });
380
+ const editing = await ensureEditing({ waitMs: 1800, runProjectId: options.runProjectId });
376
381
  if (!editing.ok) {
377
382
  return buildEditingRequiredBlockedResult(operations, editing);
378
383
  }
@@ -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, {
@@ -676,13 +736,13 @@
676
736
  return decorated;
677
737
  }
678
738
  current = ready.text;
679
- freshness = { ok: true };
739
+ freshness = ready.freshness;
680
740
  }
681
741
 
682
742
  let nextContent = null;
683
743
  let preparedPatches;
684
744
  if (Array.isArray(operation.patches) && operation.patches.length) {
685
- preparedPatches = window.CodexOverleafStaleGuard?.prepareOperationPatches(operation, current, options.baseFileLookup)
745
+ preparedPatches = freshness.preparedPatches || window.CodexOverleafStaleGuard?.prepareOperationPatches(operation, current, options.baseFileLookup)
686
746
  || { ok: true, patches: operation.patches };
687
747
  const patched = preparedPatches.ok ? applyTextPatches(current, preparedPatches.patches) : preparedPatches;
688
748
  if (!patched.ok) {
@@ -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()
@@ -736,12 +797,6 @@
736
797
  operationType: 'undo', changedDocument: false,
737
798
  userMessage: 'The project identity guard is unavailable; the undo write was not applied.' }) };
738
799
  if (blocked) return blocked.skipped?.[0]?.result || blocked;
739
- if (getActiveFilePath() !== operation.path || readActiveEditorText() !== current) {
740
- const reason = 'The editor changed while confirming Editing mode; the undo write was not applied.';
741
- return { ok: false, code: 'stale_source_changed', reason,
742
- failure: buildPageFailure('stale_source_changed', { file: operation.path,
743
- operationType: 'undo', changedDocument: false, userMessage: reason }) };
744
- }
745
800
  if (!isEditingConfirmedForNoTraceUndo(getReviewingState({})))
746
801
  return buildNoTraceUndoBlockedResult([operation], { ok: false, code: 'editing_not_confirmed',
747
802
  reason: 'Overleaf left Editing mode before the undo write.' }).skipped[0].result;
@@ -749,16 +804,40 @@
749
804
 
750
805
  const finalReviewBlock = options.reviewGuard?.();
751
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
+ }
818
+ // Read and dispatch synchronously against the same document. Navigation,
819
+ // freshness polling and Undo mode/project checks can all have awaited.
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) {
825
+ const reason = 'The editor changed before the edit could be applied; the document was not overwritten.';
826
+ return { ok: false, code: 'stale_source_changed', reason,
827
+ failure: buildPageFailure('stale_source_changed', { file: operation.path,
828
+ operationType: options.noTraceUndo ? 'undo' : 'edit', changedDocument: false, userMessage: reason }) };
829
+ }
752
830
  const result = Array.isArray(operation.patches) && operation.patches.length
753
- ? replaceActiveEditorPatches(preparedPatches.patches, nextContent)
754
- : replaceActiveEditorText(nextContent);
831
+ ? replaceActiveEditorPatches(preparedPatches.patches, nextContent, { expectedCapture: provenance.capture })
832
+ : replaceActiveEditorText(nextContent, { expectedCapture: provenance.capture });
755
833
  if (!result.ok) {
756
834
  return result;
757
835
  }
758
836
 
759
837
  const verifyWaitMs = Math.min(deps.writeVerifyWaitMs ?? 2500, Number.isFinite(options.deadlineAt)
760
838
  ? Math.max(0, options.deadlineAt - now()) : Infinity);
761
- 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 });
762
841
  if (!verified.ok) {
763
842
  const decorated = attachWritebackDebug(verified, 'write_verification', operation, options.baseFileLookup, readActiveEditorText(), {
764
843
  initialActivePath: currentPath,
@@ -827,6 +906,7 @@
827
906
  }),
828
907
  verified: true,
829
908
  verifiedContent: nextContent,
909
+ ...(preparedPatches ? { appliedPatches: preparedPatches.patches } : {}),
830
910
  trackedChanges: mergeTrackedChangeRefs(trackedChanges)
831
911
  };
832
912
  }
@@ -840,22 +920,25 @@
840
920
  ...result,
841
921
  verified: true,
842
922
  verifiedContent: nextContent,
923
+ ...(preparedPatches ? { appliedPatches: preparedPatches.patches } : {}),
843
924
  trackedChangeCapture,
844
925
  trackedChanges: mergeTrackedChangeRefs(trackedChanges)
845
926
  };
846
927
  }
847
928
 
848
- async function verifyActiveEditorText(expected, filePath, waitMs = 1000, before) {
929
+ async function verifyActiveEditorText(expected, filePath, waitMs = 1000, before, identityContext = documentIdentityContext(filePath)) {
849
930
  const deadline = Date.now() + waitMs;
850
- let actual = readActiveEditorText();
851
- while (actual !== expected && Date.now() < deadline) {
852
- 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) {
853
934
  actual = readActiveEditorText();
854
- }
855
- if (actual === expected) {
856
- return {
857
- ok: true
858
- };
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()));
859
942
  }
860
943
  return {
861
944
  ok: false,
@@ -863,7 +946,8 @@
863
946
  reason: `${filePath || '当前文件'} 写入后读回内容和 Codex 预期不一致,已停止把这次操作标记为成功。请刷新 Overleaf 后重试。`,
864
947
  expectedLength: String(expected || '').length,
865
948
  actualLength: String(actual || '').length,
866
- evidence: describeReadbackMismatch(expected, actual, before, filePath)
949
+ evidence: { ...describeReadbackMismatch(expected, actual, before, filePath),
950
+ activeMatchesTarget: identityConfirmed, identityState: provenance.identityState }
867
951
  };
868
952
  }
869
953
 
@@ -893,7 +977,8 @@
893
977
  return hash.toString(16).padStart(8, '0');
894
978
  }
895
979
 
896
- async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath) {
980
+ async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath, provenanceReferences,
981
+ identityContext = documentIdentityContext(operation.path), deadlineAt = Infinity) {
897
982
  if (!operation?.path) {
898
983
  return {
899
984
  ok: true,
@@ -908,6 +993,7 @@
908
993
  const currentText = readActiveEditorText();
909
994
  const previousEditorIdentity = getActiveEditorIdentity();
910
995
  const previousSignature = contentSignature(currentText);
996
+ const initialProvenance = documentProvenance(filePath, currentText, provenanceReferences, identityContext);
911
997
 
912
998
  const opened = await openFileByPath(filePath, { force: initialActivePath === filePath });
913
999
  if (!opened.ok) {
@@ -952,7 +1038,11 @@
952
1038
  }
953
1039
 
954
1040
  return waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, 9000, {
1041
+ identityContext,
1042
+ deadlineAt,
955
1043
  initialActivePath,
1044
+ initialProvenance,
1045
+ provenanceReferences,
956
1046
  previousSignature,
957
1047
  forceSettledOpen: true,
958
1048
  openedMethod: opened.method || ''
@@ -960,52 +1050,43 @@
960
1050
  }
961
1051
 
962
1052
  async function waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, timeoutMs, options = {}) {
963
- const deadline = Date.now() + timeoutMs;
1053
+ const deadline = Math.min(options.deadlineAt ?? Infinity, Date.now() + timeoutMs);
964
1054
  const startedAt = Date.now();
1055
+ const identityDeadline = Math.min(deadline, startedAt + Math.min(1500, writebackOpenSettleMs || TreeOperations.ACTIVE_EDITOR_STABILITY_MS));
965
1056
  const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
966
1057
  let lastText = readActiveEditorText();
967
1058
  let lastActivePath = getActiveFilePath();
1059
+ let mismatch = null;
968
1060
  while (Date.now() < deadline) {
969
1061
  lastActivePath = getActiveFilePath();
970
1062
  lastText = readActiveEditorText();
971
1063
  const activeFileMatches = lastActivePath === filePath;
972
1064
  const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
973
- const baseComparison = compareEditorTextToBase(filePath, lastText, baseFileLookup);
974
- const baseMatches = baseComparison.matches;
975
1065
  const settledLongEnough = Date.now() - startedAt >= minSettleMs;
976
1066
  const contentChangedFromPrevious = !options.previousSignature
977
1067
  || contentSignature(lastText) !== options.previousSignature;
978
- // Switch confirmation must not depend on any single fragile signal
979
- // (v1.6: identity comparison proved unreliable against newer Overleaf
980
- // view lifecycles, which made every write to a non-active file time
981
- // out as target_editor_not_ready). Acceptors, strongest first:
982
- // 1. editor identity visibly changed (fast path when it works);
983
- // 2. the editor shows EXACTLY the target file's expected base —
984
- // the strongest possible proof the right document is loaded;
985
- // 3. same-file reopen settled with unchanged content;
986
- // 4. base UNKNOWN only: the content moved off the previous document
987
- // and the settle window elapsed. When the base IS known, content
988
- // must match it (acceptor 2) — a weakly-anchored patch (e.g. an
989
- // insert into an empty file, expected:'') would otherwise anchor
990
- // trivially into a wrong-but-different document.
991
- const switchConfirmed = identityChanged
992
- || baseMatches
993
- || (options.initialActivePath === filePath
994
- && options.previousSignature
995
- && contentSignature(lastText) === options.previousSignature
996
- && settledLongEnough)
997
- || (!baseComparison.known && contentChangedFromPrevious && settledLongEnough);
998
- const contentNoLongerLooksLikePreviousDocument = contentChangedFromPrevious
999
- || baseMatches
1000
- || options.initialActivePath === filePath;
1001
- if (activeFileMatches && switchConfirmed && settledLongEnough && contentNoLongerLooksLikePreviousDocument) {
1068
+ const provenance = documentProvenance(filePath, lastText, options.provenanceReferences, options.identityContext);
1069
+ lastText = provenance.capture?.text ?? lastText;
1070
+ mismatch = provenance.ok ? null : provenance;
1071
+ // Confirm navigation independently of source freshness. A stale target
1072
+ // can be correctly open; identical baseline bytes alone cannot prove a
1073
+ // switch. Content movement remains the fallback for editor lifecycles
1074
+ // whose document identity cannot be observed reliably.
1075
+ const switchConfirmed = provenance.identityState === 'match' || identityChanged
1076
+ || (options.initialActivePath === filePath && Boolean(previousEditorIdentity)
1077
+ && options.initialProvenance?.ok === true && provenance.ok)
1078
+ || (contentChangedFromPrevious && settledLongEnough);
1079
+ if (activeFileMatches && switchConfirmed && settledLongEnough && provenance.ok
1080
+ && (provenance.identityState === 'match' || Date.now() >= identityDeadline)) {
1002
1081
  return {
1003
1082
  ok: true,
1004
- text: lastText
1083
+ text: lastText,
1084
+ capture: provenance.capture
1005
1085
  };
1006
1086
  }
1007
- await delay(100);
1087
+ await delay(Math.max(0, Math.min(100, deadline - Date.now())));
1008
1088
  }
1089
+ if (mismatch) return documentMismatchResult(filePath, mismatch);
1009
1090
  // §9.1: if the active file ended up on a different document, this is
1010
1091
  // target_file_not_active; if the file did become active but the editor
1011
1092
  // document never settled, this is target_editor_not_ready.
@@ -1050,21 +1131,30 @@
1050
1131
  const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
1051
1132
  let lastText = readActiveEditorText();
1052
1133
  let lastActivePath = getActiveFilePath();
1134
+ const identityContext = options.identityContext || documentIdentityContext(filePath);
1135
+ let mismatch = null;
1053
1136
  while (Date.now() < deadline) {
1054
1137
  lastActivePath = getActiveFilePath();
1055
1138
  lastText = readActiveEditorText();
1139
+ const provenance = documentProvenance(filePath, lastText, options.references, identityContext);
1140
+ lastText = provenance.capture?.text ?? lastText;
1141
+ mismatch = provenance.ok ? null : provenance;
1056
1142
  const activeFileMatches = lastActivePath === filePath;
1057
1143
  const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
1058
1144
  const settledLongEnough = Date.now() - startedAt >= minSettleMs;
1059
1145
  const sameFileReopenSettled = options.initialActivePath === filePath && settledLongEnough;
1060
- 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)) {
1061
1149
  return {
1062
1150
  ok: true,
1063
- text: lastText
1151
+ text: lastText,
1152
+ capture: provenance.capture
1064
1153
  };
1065
1154
  }
1066
- await delay(100);
1155
+ await delay(Math.max(0, Math.min(100, deadline - Date.now())));
1067
1156
  }
1157
+ if (mismatch) return documentMismatchResult(filePath, mismatch);
1068
1158
  return {
1069
1159
  ok: false,
1070
1160
  code: 'editor_document_not_switched',
@@ -1074,69 +1164,32 @@
1074
1164
  };
1075
1165
  }
1076
1166
 
1077
- function editorContentMatchesBase(filePath, content, baseFileLookup) {
1078
- return compareEditorTextToBase(filePath, content, baseFileLookup).matches;
1079
- }
1080
-
1081
- function compareEditorTextToBase(filePath, content, baseFileLookup) {
1082
- const normalizedPath = normalizeSafeProjectPath(filePath);
1083
- const current = normalizeEditorTextForComparison(content);
1084
- if (!baseFileLookup || !normalizedPath || !baseFileLookup.has(normalizedPath)) {
1085
- return {
1086
- known: false,
1087
- hasContent: false,
1088
- matches: false,
1089
- baseLength: null,
1090
- currentLength: current.length
1091
- };
1092
- }
1093
- const base = normalizeEditorTextForComparison(baseFileLookup.get(normalizedPath));
1094
- const matches = current === base;
1095
- if (!matches && base.trim().length > 0) {
1096
- console.warn('[codex-overleaf] editorContentMatchesBase MISMATCH', filePath,
1097
- 'base.length:', base.length, 'current.length:', current.length,
1098
- 'base[0..80]:', JSON.stringify(base.slice(0, 80)),
1099
- 'current[0..80]:', JSON.stringify(current.slice(0, 80)));
1100
- }
1101
- return {
1102
- known: true,
1103
- hasContent: base.trim().length > 0,
1104
- matches,
1105
- baseLength: base.length,
1106
- currentLength: current.length
1107
- };
1108
- }
1109
-
1110
- function hasBaseFileContent(filePath, baseFileLookup) {
1111
- if (!baseFileLookup || !filePath || !baseFileLookup.has(filePath)) {
1112
- return false;
1113
- }
1114
- const content = baseFileLookup.get(filePath);
1115
- return typeof content === 'string' && content.trim().length > 0;
1116
- }
1117
-
1118
1167
  function normalizeEditorTextForComparison(value) {
1119
1168
  const normalized = String(value ?? '').replace(/\r\n/g, '\n');
1120
1169
  return normalized.trim().length === 0 ? '' : normalized;
1121
1170
  }
1122
1171
 
1123
- async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500) {
1172
+ async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500, identityContext, references) {
1124
1173
  const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
1125
1174
  while (Date.now() < deadline) {
1126
1175
  if (operation?.path && getActiveFilePath() !== operation.path) {
1127
1176
  await delay(60);
1128
1177
  continue;
1129
1178
  }
1130
- const text = readActiveEditorText();
1179
+ const observed = readActiveEditorText();
1180
+ const provenance = documentProvenance(operation.path, observed, references, identityContext);
1181
+ const text = provenance.capture?.text ?? observed;
1131
1182
  const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
1132
1183
  operation,
1133
1184
  text,
1134
1185
  baseFileLookup
1135
1186
  ) || { ok: true };
1136
- if (freshness.ok) {
1187
+ if (freshness.ok && provenance.ok) {
1137
1188
  return {
1138
1189
  ok: true,
1139
- text
1190
+ text,
1191
+ freshness,
1192
+ capture: provenance.capture
1140
1193
  };
1141
1194
  }
1142
1195
  await delay(60);
@@ -1485,6 +1538,7 @@
1485
1538
  const expected = options.baseFileLookup?.get(operation.path);
1486
1539
  const text = proof?.v === 1 && proof.kind === 'text' && typeof expected === 'string';
1487
1540
  const binary = proof?.v === 1 && proof.kind === 'binary' && /^[a-f0-9]{64}$/i.test(proof.sha256 || '');
1541
+ const identityContext = documentIdentityContext(operation.path, projectId);
1488
1542
  const isCurrent = () => Boolean(projectId) && projectId === options.runProjectId
1489
1543
  && treeOperations.getProjectId?.() === projectId && readWriteCancellationSequence() === cancellation && !options.reviewGuard?.();
1490
1544
  if (!isCurrent() || (!text && !binary) || typeof deps.deleteTextFile !== 'function') {
@@ -1492,7 +1546,7 @@
1492
1546
  reason: 'Verified created-file deletion is unavailable for this run.' };
1493
1547
  }
1494
1548
  if (text && projectPathExists(operation.path)) {
1495
- const current = await readCurrentTextFileForFreshness(operation.path);
1549
+ const current = await readCurrentTextFileForFreshness(operation.path, options.baseFileLookup);
1496
1550
  if (!current.ok || current.text !== expected || !isCurrent()) {
1497
1551
  return { ok: false, code: 'undo_operation_failed', changedDocument: false,
1498
1552
  reason: 'The created file changed after this run and was left untouched.' };
@@ -1501,9 +1555,10 @@
1501
1555
  const result = await deps.deleteTextFile(operation, {
1502
1556
  isCurrent, undoCreatedFile: true, deadlineAt: options.deadlineAt,
1503
1557
  expectedContent: text ? expected : undefined,
1504
- prepareTextPreimage: text ? () => readCurrentTextFileForFreshness(operation.path) : undefined,
1558
+ prepareTextPreimage: text ? () => readCurrentTextFileForFreshness(operation.path, options.baseFileLookup) : undefined,
1505
1559
  expectedSha256: binary ? proof.sha256.toLowerCase() : undefined,
1506
1560
  canDelete: () => isCurrent() && (binary || (treeOperations.getActiveFilePath?.() === operation.path
1561
+ && documentProvenance(operation.path, readActiveEditorText(), options.baseFileLookup, identityContext).ok
1507
1562
  && readActiveEditorText() === expected))
1508
1563
  });
1509
1564
  if (!result.ok) return result;
@@ -1606,7 +1661,7 @@
1606
1661
  };
1607
1662
  }
1608
1663
 
1609
- const current = await readCurrentTextFileForFreshness(operation.path);
1664
+ const current = await readCurrentTextFileForFreshness(operation.path, baseFileLookup);
1610
1665
  if (!current.ok) {
1611
1666
  return current;
1612
1667
  }
@@ -1633,13 +1688,14 @@
1633
1688
  return { ok: true };
1634
1689
  }
1635
1690
 
1636
- async function readCurrentTextFileForFreshness(filePath) {
1691
+ async function readCurrentTextFileForFreshness(filePath, references) {
1637
1692
  const normalizedPath = normalizeSafeProjectPath(filePath);
1638
1693
  if (!normalizedPath) {
1639
1694
  return invalidProjectPathResult('freshness path');
1640
1695
  }
1641
1696
  const currentPath = getActiveFilePath();
1642
1697
  const previousEditorIdentity = getActiveEditorIdentity();
1698
+ const identityContext = documentIdentityContext(normalizedPath);
1643
1699
  const opened = await openFileByPath(normalizedPath, { force: true });
1644
1700
  if (!opened.ok) {
1645
1701
  return {
@@ -1649,6 +1705,8 @@
1649
1705
  };
1650
1706
  }
1651
1707
  const ready = await waitForEditorDocumentForPath(normalizedPath, previousEditorIdentity, 9000, {
1708
+ identityContext,
1709
+ references,
1652
1710
  initialActivePath: currentPath,
1653
1711
  forceSettledOpen: true
1654
1712
  });
@@ -1895,6 +1953,7 @@
1895
1953
  ok: false,
1896
1954
  code: reviewing?.code || 'reviewing_not_enabled',
1897
1955
  reason: reviewing?.reason || 'Overleaf Reviewing/Track Changes was not confirmed before writing. Codex did not change this file.',
1956
+ evidence: reviewing?.evidence, failure: reviewing?.failure,
1898
1957
  reviewing: reviewing?.reviewing || null
1899
1958
  }
1900
1959
  })),