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
@@ -246,6 +246,8 @@ function undoOwner(run) {
246
246
  async function executeUndo(runId) {
247
247
  let run = findRunRecord(runId);
248
248
  if (run) Object.assign(run, Modules.UndoOperations.prepareRunUndo(run));
249
+ const mirrorOwner = { projectId: getRunProjectIdForWriteback(run), runAccountScopeId: run?.runAccountScopeId || getAccountScopeId?.() || '',
250
+ sessionId: getState().activeSessionId };
249
251
  const assertCurrent = undoOwner(run);
250
252
  let assertInputs = checkpointGuard(run, assertCurrent);
251
253
  if (!getRunUndoCount(run) || run.undoStatus === 'applied') {
@@ -338,9 +340,11 @@ async function executeUndo(runId) {
338
340
  // Undo remains bound to its original project, including after navigation.
339
341
  runProjectId: getRunProjectIdForWriteback(run)
340
342
  });
343
+ // The Page may have changed the original document even if this view moved
344
+ // while awaiting its reply. Fence that project's mirror before rechecking.
345
+ writebackOrchestrator.invalidateMirrorAfterUndo(runId, mirrorOwner.projectId, result, mirrorOwner);
341
346
  run = assertInputs();
342
347
  const undoApplied = isUndoResultEffectivelyApplied(run, result);
343
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
344
348
  appendUndoReviewingPolicyEvent(runId, result.reviewingPolicy);
345
349
  appendRunRecordEvent(runId, {
346
350
  title: tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -369,6 +373,8 @@ async function executeUndo(runId) {
369
373
  }
370
374
 
371
375
  async function executeTrackedUndo(runId, run) {
376
+ const mirrorOwner = { projectId: getRunProjectIdForWriteback(run), runAccountScopeId: run?.runAccountScopeId || getAccountScopeId?.() || '',
377
+ sessionId: getState().activeSessionId };
372
378
  const assertCurrent = undoOwner(run);
373
379
  let assertInputs = checkpointGuard(run, assertCurrent);
374
380
  const fileUndoOperations = Modules.UndoOperations.getFileUndoOperations(run);
@@ -452,8 +458,8 @@ async function executeTrackedUndo(runId, run) {
452
458
  operation, result: { ok: false, code: 'undo_operation_failed', reason: error.message }
453
459
  })) };
454
460
  }
455
- run = assertInputs();
456
461
  result = Modules.UndoOperations.mergeFileUndoResult(result, createdResult, fileUndoOperations);
462
+ run = assertInputs();
457
463
  }
458
464
  appendRunRecordEvent(runId, {
459
465
  title: fileUndoOperations.length ? tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }) : trackedUndo
@@ -484,7 +490,7 @@ async function executeTrackedUndo(runId, run) {
484
490
  await applyLegacyUndoSettlement(runId, result.ok === true && !result.skipped?.length ? 'applied' : 'partial', result);
485
491
  } finally {
486
492
  // Known Page changes require invalidation even when a durable checkpoint fails.
487
- if (result) writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
493
+ if (result) writebackOrchestrator.invalidateMirrorAfterUndo(runId, mirrorOwner.projectId, result, mirrorOwner);
488
494
  }
489
495
  }
490
496
 
@@ -134,6 +134,19 @@
134
134
  }
135
135
  }
136
136
 
137
+ function projectPreferenceValues(input, prefs, projectId) {
138
+ return {
139
+ experimentalOtByProject: input.normalizeExperimentalOtByProject(prefs.experimentalOtByProject)[projectId] === true,
140
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject?.[projectId],
141
+ governanceRulesByProject: input.normalizeGovernanceRulesByProject({
142
+ [projectId]: prefs.governanceRulesByProject?.[projectId] || {}
143
+ })[projectId],
144
+ customInstructionsByProject: input.normalizeCustomInstructionsByProject({
145
+ [projectId]: prefs.customInstructionsByProject?.[projectId] || ''
146
+ })[projectId]
147
+ };
148
+ }
149
+
137
150
  async function persistPanelState(input) {
138
151
  var state = cloneSnapshot(input.state || {});
139
152
  var compactState = cloneSnapshot(input.compactState || {});
@@ -163,6 +176,11 @@
163
176
  var reviewRuns = (input.reviewRunIds || []).map(id =>
164
177
  (state.sessions || []).flatMap(session => session.runs || []).find(run => run.id === id));
165
178
  if (reviewRuns.some(run => !run)) throw reviewPersistenceError();
179
+ var preferenceBaseline = input.projectPreferenceBaseline
180
+ || Migration.getProjectPreferenceBaseline?.(accountScopeId, projectId);
181
+ var preferenceWrite = input.projectPreferenceWrite || Migration.captureProjectPreferenceWrite?.({
182
+ ...input, state: state, baseline: preferenceBaseline, accountScopeId: accountScopeId, projectId: projectId
183
+ });
166
184
  var latestPrefs = typeof Migration.loadPrefs === 'function'
167
185
  ? await Migration.loadPrefs(accountScopeId, projectId)
168
186
  : {};
@@ -176,32 +194,23 @@
176
194
  projectId,
177
195
  compactState.activeSessionId || state.activeSessionId || ''
178
196
  );
179
- prefs.experimentalOtByProject = Object.assign(
180
- {},
181
- latestPrefs && latestPrefs.experimentalOtByProject || {},
182
- { [projectId]: input.normalizeExperimentalOtByProject(state.experimentalOtByProject)[projectId] === true }
183
- );
184
- prefs.reviewingAvailabilityByProject = Object.assign({}, latestPrefs?.reviewingAvailabilityByProject || {},
185
- state.reviewingAvailabilityByProject || {});
186
- prefs.governanceRulesByProject = Object.assign(
187
- {},
188
- latestPrefs && latestPrefs.governanceRulesByProject || {},
189
- input.normalizeGovernanceRulesByProject(state.governanceRulesByProject)
190
- );
191
- var normalizedCustomProject = input.normalizeCustomInstructionsByProject({ [projectId]: '' });
192
- var customProjectId = Object.keys(normalizedCustomProject)[0] || '';
193
- var currentCustom = input.normalizeCustomInstructionsByProject(state.customInstructionsByProject);
194
- prefs.customInstructionsByProject = Object.assign(
195
- {},
196
- latestPrefs && latestPrefs.customInstructionsByProject || {}
197
- );
198
- if (customProjectId) {
199
- prefs.customInstructionsByProject[customProjectId] =
200
- Object.prototype.hasOwnProperty.call(currentCustom, customProjectId)
201
- ? currentCustom[customProjectId]
202
- : '';
197
+ var currentPreferences = projectPreferenceValues(input, state, projectId);
198
+ if (preferenceWrite) {
199
+ Object.assign(prefs, Migration.projectPreferencePatch(preferenceWrite, accountScopeId, projectId));
200
+ } else {
201
+ var baselinePreferences = projectPreferenceValues(input, preferenceBaseline || latestPrefs || {}, projectId);
202
+ for (var name of Object.keys(currentPreferences)) {
203
+ prefs[name] = JSON.stringify(currentPreferences[name]) === JSON.stringify(baselinePreferences[name])
204
+ ? {} : { [projectId]: currentPreferences[name] };
205
+ }
206
+ }
207
+ var savedPrefs = await Migration.savePrefs(prefs, accountScopeId, projectId);
208
+ if (savedPrefs) {
209
+ var fields = preferenceWrite ? Migration.completeProjectPreferenceWrite(preferenceWrite, savedPrefs)
210
+ : Object.keys(currentPreferences);
211
+ var applied = input.applyProjectPreferences?.(savedPrefs, state, fields);
212
+ if (!preferenceWrite && applied !== false) Migration.adoptProjectPreferenceBaseline?.(savedPrefs, accountScopeId, projectId);
203
213
  }
204
- await Migration.savePrefs(prefs, accountScopeId, projectId);
205
214
  var persistenceMeta = input.persistenceContext?.nextMeta || {};
206
215
  var queueTombstones = persistenceMeta.queueTombstones || {};
207
216
  var sessionTombstones = persistenceMeta.sessionTombstones || {};
@@ -133,6 +133,28 @@
133
133
  }
134
134
  }
135
135
 
136
+ async function attachInstruction(action, selected) {
137
+ const selection = await attach('edit', selected);
138
+ if (!selection) return;
139
+ const task = deps.getPanel()?.querySelector('[data-task]');
140
+ if (!task) return;
141
+ if (task.value.trim()) {
142
+ deps.toast?.(tx('Kept your draft. The selection is attached for editing.', '已保留你的草稿,选区已附上,仅编辑此处。'));
143
+ return;
144
+ }
145
+ const instructions = {
146
+ polish: tx('Polish the selected passage for clarity and flow. Keep the meaning, citations, labels and math unchanged.',
147
+ '润色选中段落,让表达更清晰流畅,保持原意以及引用、标签、公式不变。'),
148
+ shorten: tx('Shorten the selected passage by about 20%. Keep citations, labels, math and the main claim unchanged.',
149
+ '将选中段落精简约 20%,保留引用、标签、公式和核心论点不变。'),
150
+ translate: tx('Translate the selected passage into English. Keep LaTeX commands, citations, labels and math unchanged.',
151
+ '将选中段落翻译成英文,保留 LaTeX 命令、引用、标签和公式不变。')
152
+ };
153
+ task.value = instructions[action];
154
+ task.setSelectionRange(task.value.length, task.value.length);
155
+ task.dispatchEvent(new Event('input', { bubbles: true }));
156
+ }
157
+
136
158
  function editorFor(node) {
137
159
  const element = node?.nodeType === 1 ? node : node?.parentElement;
138
160
  const editor = element?.closest?.('.cm-editor');
@@ -164,6 +186,9 @@
164
186
  toolbar.setAttribute('aria-label', tx('Selected text actions', '选区操作'));
165
187
  toolbar.querySelector('[data-selection-action="reference"]').textContent = tx('Add to Chat', '加入对话');
166
188
  toolbar.querySelector('[data-selection-action="edit"]').textContent = tx('Edit Selection', '仅编辑此处');
189
+ toolbar.querySelector('[data-selection-action="polish"]').textContent = tx('Polish', '润色');
190
+ toolbar.querySelector('[data-selection-action="shorten"]').textContent = tx('Shorten', '精简');
191
+ toolbar.querySelector('[data-selection-action="translate"]').textContent = tx('Translate', '翻译');
167
192
  }
168
193
 
169
194
  function ensureToolbar() {
@@ -174,8 +199,8 @@
174
199
  toolbar.setAttribute('role', 'toolbar');
175
200
  toolbar.hidden = true;
176
201
  if (typeof toolbar.showPopover === 'function') toolbar.setAttribute('popover', 'manual');
177
- for (const mode of ['reference', 'edit']) {
178
- if (mode === 'edit') {
202
+ for (const mode of ['reference', 'edit', 'polish', 'shorten', 'translate']) {
203
+ if (mode === 'edit' || mode === 'polish') {
179
204
  const divider = document.createElement('span');
180
205
  divider.className = 'codex-selection-action-divider';
181
206
  divider.setAttribute('aria-hidden', 'true');
@@ -186,7 +211,9 @@
186
211
  button.dataset.selectionAction = mode;
187
212
  button.addEventListener('click', () => {
188
213
  const selected = candidate;
189
- if (selected) void attach(mode, selected);
214
+ if (!selected) return;
215
+ if (mode === 'reference' || mode === 'edit') void attach(mode, selected);
216
+ else void attachInstruction(mode, selected);
190
217
  });
191
218
  toolbar.append(button);
192
219
  }
@@ -9,7 +9,11 @@
9
9
 
10
10
  function applySnapshot(options, sessions) {
11
11
  const previous = options.getState(), scope = options.getScope();
12
- const next = options.normalize({ ...previous, sessions }, { restoreRunningRuns: false });
12
+ const input = sessions.length ? { ...previous, sessions } : {
13
+ ...previous, session: null, sessions: [], runs: [], activeSessionId: '',
14
+ task: '', focusFiles: [], selectionContext: null, composerAttachments: []
15
+ };
16
+ const next = options.normalize(input, { restoreRunningRuns: false });
13
17
  const unchangedSnapshot = JSON.stringify(next);
14
18
  options.setState(next);
15
19
  try {
@@ -88,6 +92,10 @@
88
92
  }
89
93
  const unsubscribe = Bridge.subscribe(change => {
90
94
  const scope = getScope();
95
+ if (options.isDashboard?.()) {
96
+ if (scope.accountScopeId && change.accountScopeId === scope.accountScopeId) options.refreshDashboard?.();
97
+ return;
98
+ }
91
99
  if (!change.projectId || change.projectId === scope.projectId) {
92
100
  retryCount = 0; pending = true; schedule();
93
101
  }
@@ -21,6 +21,12 @@
21
21
  let appendRunRecordEvent;
22
22
  // In-flight background mirror refresh (v1.7.5) — see the writeback path.
23
23
  let pendingMirrorRefresh = null;
24
+ const mirrorSettlementOwners = new Map();
25
+ const mirrorReuseFences = new Map();
26
+ const mirrorFenceWrites = new Map();
27
+ const mirrorFenceRevisions = new Map();
28
+ const MIRROR_INVALIDATION_TIMEOUT_MS = 20000;
29
+ const MIRROR_FENCE_STORAGE_TIMEOUT_MS = 2000;
24
30
  let appendChangeSummary;
25
31
  let appendCompletionReport;
26
32
  let appendOperationsPreview;
@@ -31,6 +37,8 @@
31
37
  let callPageBridge;
32
38
  let sendBackgroundNative;
33
39
  let getCurrentProjectId;
40
+ let getAccountScopeId;
41
+ let mirrorFenceStore;
34
42
  let resetContextProject;
35
43
  let sanitizeRunProjectSnapshot;
36
44
  let getAssistantAnswerForCurrentRun;
@@ -72,27 +80,158 @@
72
80
  : null;
73
81
  }
74
82
 
75
- function invalidateMirrorAfterUndo(runId, projectId, result = {}) {
76
- if (!projectId || (result?.changedDocument !== true && !result?.applied?.length)) return Promise.resolve('not-attempted');
77
- const pending = Promise.resolve(pendingMirrorRefresh).catch(() => {}).then(async () => {
78
- const response = await sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId } });
79
- if (!response?.ok || response.result?.invalidated !== true) {
80
- throw new Error(response?.error?.message || 'Native host did not confirm mirror invalidation.');
83
+ function mirrorFenceScope(projectId, accountScopeId = getAccountScopeId?.() || '') {
84
+ return { projectId, accountScopeId };
85
+ }
86
+
87
+ function mirrorFenceKey(scope) {
88
+ return JSON.stringify([scope.accountScopeId, scope.projectId]);
89
+ }
90
+
91
+ function getMirrorReuseFence(projectId, accountScopeId) {
92
+ return mirrorReuseFences.get(mirrorFenceKey(mirrorFenceScope(projectId, accountScopeId))) || null;
93
+ }
94
+
95
+ function updateMirrorReuseFence(scope, generations, unknown = false) {
96
+ const key = mirrorFenceKey(scope), current = mirrorReuseFences.get(key);
97
+ const ids = [...new Set(generations)].sort();
98
+ if (current && current.unknown === unknown && current.generations.join(':') === ids.join(':')) return current;
99
+ const fence = ids.length || unknown ? { scope, generations: ids, unknown, syncAttempted: false } : null;
100
+ if (fence) mirrorReuseFences.set(key, fence);
101
+ else mirrorReuseFences.delete(key);
102
+ mirrorFenceRevisions.set(key, (mirrorFenceRevisions.get(key) || 0) + 1);
103
+ return fence;
104
+ }
105
+
106
+ async function awaitMirrorFenceStorage(work) {
107
+ let timer;
108
+ try {
109
+ return await Promise.race([
110
+ Promise.resolve().then(work),
111
+ new Promise((_resolve, reject) => {
112
+ timer = setTimeout(() => reject(new Error('Mirror fence storage timed out.')), MIRROR_FENCE_STORAGE_TIMEOUT_MS);
113
+ })
114
+ ]);
115
+ } finally { clearTimeout(timer); }
116
+ }
117
+
118
+ async function refreshMirrorReuseFence(projectId, accountScopeId) {
119
+ const scope = mirrorFenceScope(projectId, accountScopeId), key = mirrorFenceKey(scope);
120
+ if (!mirrorFenceStore) return getMirrorReuseFence(projectId, scope.accountScopeId);
121
+ const revision = mirrorFenceRevisions.get(key) || 0;
122
+ try {
123
+ const stored = await awaitMirrorFenceStorage(() => mirrorFenceStore.getMirrorReuseFences(scope));
124
+ const writes = mirrorFenceWrites.get(key) || new Map();
125
+ const changed = revision !== (mirrorFenceRevisions.get(key) || 0);
126
+ const generations = new Set(stored.filter(id => !writes.get(id)?.retired));
127
+ for (const [id, write] of writes) {
128
+ if (!write.retired && (!write.persisted || changed)) generations.add(id);
81
129
  }
82
- if (getCurrentProjectId() === projectId) {
83
- resetContextProject();
84
- await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true });
130
+ // A read started before a local write or newer read cannot erase that evidence.
131
+ if (changed) for (const id of mirrorReuseFences.get(key)?.generations || []) {
132
+ if (!writes.get(id)?.retired) generations.add(id);
85
133
  }
86
- return 'invalidated';
87
- }).catch(error => {
88
- appendRunRecordEvent?.(runId, {
89
- status: 'warning',
90
- title: tx(
91
- `Overleaf undo finished, but local mirror invalidation failed: ${error.message}. Refresh this project or reconnect the native host before running again.`,
92
- `Overleaf 撤销已完成,但本地镜像失效处理失败:${error.message}。请刷新项目或重新连接 Native Host 后再运行。`
93
- )
94
- });
95
- return 'failed';
134
+ return updateMirrorReuseFence(scope, [...generations]);
135
+ } catch (_error) {
136
+ return updateMirrorReuseFence(scope, mirrorReuseFences.get(key)?.generations || [], true);
137
+ }
138
+ }
139
+
140
+ async function recordMirrorReuseFence(target) {
141
+ const scope = mirrorFenceScope(target.runProjectId, target.runAccountScopeId);
142
+ const key = mirrorFenceKey(scope);
143
+ const generation = globalThis.crypto?.randomUUID?.()
144
+ || Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) + '-' + Math.random().toString(36).slice(2);
145
+ const writes = mirrorFenceWrites.get(key) || new Map();
146
+ mirrorFenceWrites.set(key, writes);
147
+ const write = { persisted: false, retired: false, clearAfterWrite: false };
148
+ writes.set(generation, write);
149
+ updateMirrorReuseFence(scope, [...(mirrorReuseFences.get(key)?.generations || []), generation]);
150
+ const token = { scope, generation };
151
+ if (!mirrorFenceStore) return token;
152
+ const pending = Promise.resolve().then(() => mirrorFenceStore.setMirrorReuseFence(scope, generation));
153
+ pending.then(() => {
154
+ write.persisted = true;
155
+ mirrorFenceRevisions.set(key, (mirrorFenceRevisions.get(key) || 0) + 1);
156
+ // A slow set may finish after a successful full sync already removed its key.
157
+ if (write.clearAfterWrite) {
158
+ awaitMirrorFenceStorage(() => mirrorFenceStore.clearMirrorReuseFences(scope, [generation]))
159
+ .then(() => { write.retired = true; }).catch(() => {});
160
+ }
161
+ }, () => {});
162
+ try { await awaitMirrorFenceStorage(() => pending); }
163
+ catch (_error) { /* The existing invalidation warning and memory fence remain. */ }
164
+ return token;
165
+ }
166
+
167
+ async function clearMirrorReuseFenceGenerations(scope, generations) {
168
+ const writes = mirrorFenceWrites.get(mirrorFenceKey(scope));
169
+ for (const id of generations) if (writes?.has(id)) writes.get(id).clearAfterWrite = true;
170
+ const clearing = Promise.resolve().then(() => mirrorFenceStore?.clearMirrorReuseFences(scope, generations));
171
+ clearing.then(() => {
172
+ for (const id of generations) if (writes?.has(id)) writes.get(id).retired = true;
173
+ }, () => {});
174
+ await awaitMirrorFenceStorage(() => clearing);
175
+ }
176
+
177
+ async function settleFullMirrorSync(projectId, project, fence, response, options = {}) {
178
+ if (!fence || fence.unknown || getMirrorReuseFence(projectId, fence.scope.accountScopeId) !== fence || response?.ok !== true
179
+ || project?.capabilities?.fullProjectSnapshot !== true
180
+ || project.capabilities.contentComplete === false || project.capabilities.skipped?.length) return false;
181
+ try {
182
+ options.assertCurrent?.();
183
+ const status = await (options.sendNative || sendBackgroundNative)({ method: 'mirror.status', params: { projectId } });
184
+ options.assertCurrent?.();
185
+ const current = await refreshMirrorReuseFence(projectId, fence.scope.accountScopeId);
186
+ options.assertCurrent?.();
187
+ if (getCurrentProjectId() !== projectId || current !== fence
188
+ || status?.ok !== true || status.result?.exists !== true || status.result.dirty === true) return false;
189
+ await clearMirrorReuseFenceGenerations(fence.scope, fence.generations);
190
+ options.assertCurrent?.();
191
+ if (getMirrorReuseFence(projectId, fence.scope.accountScopeId) === fence) updateMirrorReuseFence(fence.scope, []);
192
+ return !(await refreshMirrorReuseFence(projectId, fence.scope.accountScopeId));
193
+ } catch (_error) {
194
+ options.assertCurrent?.();
195
+ return false;
196
+ }
197
+ }
198
+
199
+ async function syncFencedMirror(projectId, project, options = {}) {
200
+ const fence = options.fence;
201
+ if (!fence || fence.unknown || getMirrorReuseFence(projectId, fence.scope.accountScopeId) !== fence || fence.syncAttempted
202
+ || project?.id !== projectId || project.capabilities?.fullProjectSnapshot !== true
203
+ || project.capabilities.contentComplete === false || project.capabilities.skipped?.length) return false;
204
+ options.assertCurrent();
205
+ // Only the next full-read path adds a sync; a failed attempt keeps reuse
206
+ // fenced without adding another request on every subsequent run.
207
+ fence.syncAttempted = true;
208
+ try {
209
+ const response = await options.sendNative({ method: 'mirror.sync', params: { projectId, project } });
210
+ options.assertCurrent();
211
+ return await settleFullMirrorSync(projectId, project, fence, response, options);
212
+ } catch (_error) {
213
+ options.assertCurrent();
214
+ return false;
215
+ }
216
+ }
217
+
218
+ function trackWritebackMirror(work, target, onSettled) {
219
+ const key = JSON.stringify([target.runProjectId, target.sessionId, target.recordId]);
220
+ const owner = {};
221
+ mirrorSettlementOwners.set(key, owner);
222
+ const refresh = Promise.resolve(work).then(async state => {
223
+ if (mirrorSettlementOwners.get(key) !== owner) return state;
224
+ onSettled?.(state);
225
+ await onMirrorRefreshSettled?.({ ...target, state: state || 'not-attempted' });
226
+ return state;
227
+ }).finally(() => {
228
+ if (mirrorSettlementOwners.get(key) === owner) mirrorSettlementOwners.delete(key);
229
+ });
230
+ // Keep earlier work in the barrier even if this attempt finishes first or
231
+ // fails. Only the latest composed barrier may clear the shared slot.
232
+ const pending = Promise.allSettled([pendingMirrorRefresh, refresh]).then(([, result]) => {
233
+ if (result.status === 'rejected') throw result.reason;
234
+ return result.value;
96
235
  }).finally(() => {
97
236
  if (pendingMirrorRefresh === pending) pendingMirrorRefresh = null;
98
237
  });
@@ -100,6 +239,21 @@
100
239
  return pending;
101
240
  }
102
241
 
242
+ function invalidateMirrorAfterUndo(runId, projectId, result = {}, owner = {}) {
243
+ if (!projectId || (result?.changedDocument !== true && !result?.applied?.length)) return Promise.resolve('not-attempted');
244
+ const target = { recordId: runId, runProjectId: projectId,
245
+ runAccountScopeId: owner.runAccountScopeId || getAccountScopeId?.() || '', sessionId: owner.sessionId || '' };
246
+ const pending = Promise.resolve(pendingMirrorRefresh).catch(() => {})
247
+ .then(() => invalidateWritebackMirror(target, {
248
+ pageFirst: true, nativeTimeoutMs: MIRROR_INVALIDATION_TIMEOUT_MS, undo: true
249
+ }))
250
+ .then(state => state === 'not-attempted' ? 'invalidated' : state).finally(() => {
251
+ if (pendingMirrorRefresh === pending) pendingMirrorRefresh = null;
252
+ });
253
+ pendingMirrorRefresh = pending;
254
+ return pending;
255
+ }
256
+
103
257
  function selectionViolationNextStep() {
104
258
  return tx('Select the intended region again and request only changes within it. Valid written parts can be undone.',
105
259
  '请重新选择目标范围,仅请求修改该范围。已写入的合法部分可撤销。');
@@ -117,7 +271,8 @@
117
271
 
118
272
  async function applySyncChangesToOverleaf(syncChanges = [], project = {}, options = {}) {
119
273
  const activeRun = getCurrentRunView?.();
120
- const saveTarget = { runProjectId: activeRun?.runProjectId || '', recordId: activeRun?.recordId || '', sessionId: activeRun?.sessionId || '' };
274
+ const saveTarget = { runProjectId: activeRun?.runProjectId || '', runAccountScopeId: activeRun?.runAccountScopeId || getAccountScopeId?.() || '',
275
+ recordId: activeRun?.recordId || '', sessionId: activeRun?.sessionId || '' };
121
276
  const runMode = options.mode || getState().mode;
122
277
  const runRequireReviewing = typeof options.requireReviewing === 'boolean'
123
278
  ? options.requireReviewing
@@ -418,9 +573,35 @@
418
573
  // zero-write run (every operation skipped) has nothing to save-verify, so
419
574
  // skip the ~5s probe and its misleading "could not verify saved" warning
420
575
  // (v1.6.2).
421
- const confirmation = appliedPaths.length
422
- ? await verifyPostWriteSaveState(applied, project, saveTarget)
423
- : { verification: { ok: false, state: 'not_checked', reason: 'No applied writeback operations were returned.' } };
576
+ let confirmation;
577
+ try {
578
+ confirmation = appliedPaths.length
579
+ ? await verifyPostWriteSaveState(applied, project, saveTarget)
580
+ : { verification: { ok: false, state: 'not_checked', reason: 'No applied writeback operations were returned.' } };
581
+ } catch (error) {
582
+ if (!appliedPaths.length || (error?.code !== 'codex_cancelled' && error?.name !== 'AbortError')) throw error;
583
+ const saveVerification = { ok: false, state: 'unknown_timeout', signal: 'unavailable',
584
+ reason: 'Save confirmation was cancelled.', recoveryReason: 'codex_cancelled', attempts: [] };
585
+ // Cancellation cannot erase the returned writes or the recovery needed to
586
+ // check their save state. Carry the same evidence into the cancelled report.
587
+ error.writebackOutcome = { operations, applied, saveVerification,
588
+ settlement: buildSettlement({ operations, applyResult: applied, saveVerification, mirror: { state: 'unknown' } }) };
589
+ // Fence the Page cache now; bound the Native wait so an unanswered
590
+ // invalidation cannot strand the next run or recovery behind the barrier.
591
+ trackWritebackMirror(invalidateWritebackMirror(saveTarget, {
592
+ pageFirst: true, nativeTimeoutMs: MIRROR_INVALIDATION_TIMEOUT_MS
593
+ }), saveTarget,
594
+ state => {
595
+ error.writebackOutcome.settlement = buildSettlement({ operations, applyResult: applied, saveVerification, mirror: { state } });
596
+ });
597
+ try {
598
+ await settleWritebackRecovery?.(applied, project, saveVerification, saveTarget, runRequireReviewing);
599
+ } catch (recoveryError) {
600
+ appendRunRecordEvent?.(saveTarget.recordId, { kind: 'technical', status: 'warning',
601
+ title: tx('Writeback recovery persistence failed', '写回恢复信息保存失败'), detail: { reason: recoveryError.message } });
602
+ }
603
+ throw error;
604
+ }
424
605
  const saveVerification = confirmation.verification;
425
606
  await settleWritebackRecovery?.(applied, project, saveVerification, saveTarget, runRequireReviewing);
426
607
  if (appliedPaths.length) {
@@ -432,24 +613,11 @@
432
613
  // pending promise is exposed so undoRun and the next runTask barrier on
433
614
  // it — a mirror.sync landing AFTER an undo would otherwise push the
434
615
  // pre-undo snapshot as the local baseline.
435
- const mirrorSettlementTarget = saveTarget;
436
- pendingMirrorRefresh = refreshProjectMirrorAfterWriteback(project, applied, saveVerification, { target: saveTarget, snapshot: confirmation.snapshot })
616
+ trackWritebackMirror(refreshProjectMirrorAfterWriteback(project, applied, saveVerification, { target: saveTarget, snapshot: confirmation.snapshot })
437
617
  .catch(async error => {
438
618
  appendRunRecordEvent(saveTarget.recordId, { kind: 'technical', title: 'Mirror refresh requires a fresh read', status: 'info', detail: { reason: error.message } });
439
619
  return invalidateWritebackMirror(saveTarget);
440
- })
441
- .then(state => {
442
- if (typeof onMirrorRefreshSettled !== 'function') return;
443
- return onMirrorRefreshSettled({
444
- recordId: mirrorSettlementTarget?.recordId || '',
445
- sessionId: mirrorSettlementTarget?.sessionId || '',
446
- runProjectId: mirrorSettlementTarget?.runProjectId || '',
447
- state: state || 'not-attempted'
448
- });
449
- })
450
- .finally(() => {
451
- pendingMirrorRefresh = null;
452
- });
620
+ }), saveTarget);
453
621
  const compileSummary = appliedPaths.length
454
622
  ? await autoRecompileAfterWriteback(appliedPaths, saveVerification, {
455
623
  autoRecompile: options.autoRecompile,
@@ -522,7 +690,9 @@
522
690
  // the run by inspecting the `applied.skipped` entries' failure codes.
523
691
  // Existing call sites that only read `summaryLine` / `hasSkippedOperations`
524
692
  // / `audit` are unaffected.
693
+ operations,
525
694
  applied,
695
+ saveVerification,
526
696
  settlement: buildSettlement({
527
697
  operations,
528
698
  applyResult: applied,
@@ -575,25 +745,64 @@
575
745
  else appendRunEvent({ title, status: 'warning' });
576
746
  }
577
747
 
578
- async function invalidateWritebackMirror(target = {}) {
748
+ async function invalidateWritebackMirror(target = {}, options = {}) {
749
+ const invalidatePage = async () => {
750
+ if (getCurrentProjectId() !== target.runProjectId
751
+ || (target.runAccountScopeId && getAccountScopeId?.() !== target.runAccountScopeId)) return true;
752
+ try {
753
+ resetContextProject();
754
+ const response = await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true, runProjectId: target.runProjectId });
755
+ return response?.ok === true;
756
+ } catch (_error) { return false; }
757
+ };
758
+ const pageInvalidation = options.pageFirst === true ? invalidatePage() : null;
759
+ // The local mirror barrier is not shared with other tabs. Publish this
760
+ // cancellation's fence before waiting for Native, including its handshake.
761
+ const pendingFence = options.pageFirst === true && target.runProjectId
762
+ ? await recordMirrorReuseFence(target) : null;
579
763
  let invalidated = false;
764
+ let failureReason = 'Native host did not confirm mirror invalidation.';
765
+ let timer;
580
766
  try {
581
767
  if (target.runProjectId) {
582
- const response = await sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId: target.runProjectId } });
768
+ const pending = sendBackgroundNative({ method: 'mirror.invalidate', params: { projectId: target.runProjectId } });
769
+ // A rejection after the bound has nobody left to observe it.
770
+ pending.catch(() => {});
771
+ const response = options.nativeTimeoutMs
772
+ ? await Promise.race([
773
+ pending,
774
+ new Promise(resolve => {
775
+ timer = setTimeout(() => resolve(null), options.nativeTimeoutMs);
776
+ })
777
+ ])
778
+ : await pending;
583
779
  invalidated = response?.ok === true && response.result?.invalidated === true;
780
+ if (!invalidated && response?.error?.message) failureReason = response.error.message;
584
781
  }
585
- } catch (_error) { /* An unavailable host cannot acknowledge invalidation. */ }
586
- let pageInvalidated = getCurrentProjectId() !== target.runProjectId;
587
- if (!pageInvalidated) {
588
- resetContextProject();
589
- try {
590
- const response = await callPageBridge('invalidateProjectSnapshot', { invalidateFileList: true, runProjectId: target.runProjectId });
591
- pageInvalidated = response?.ok === true;
592
- } catch (_error) { /* Leave the failure visible if both caches cannot be fenced. */ }
782
+ } catch (error) { failureReason = error?.message || failureReason; }
783
+ finally {
784
+ if (timer !== undefined) clearTimeout(timer);
785
+ }
786
+ const pageInvalidated = await (pageInvalidation || invalidatePage());
787
+ if (invalidated && pageInvalidated) {
788
+ if (pendingFence) {
789
+ try {
790
+ await clearMirrorReuseFenceGenerations(pendingFence.scope, [pendingFence.generation]);
791
+ const current = getMirrorReuseFence(target.runProjectId, pendingFence.scope.accountScopeId);
792
+ if (current) updateMirrorReuseFence(pendingFence.scope,
793
+ current.generations.filter(id => id !== pendingFence.generation), current.unknown);
794
+ await refreshMirrorReuseFence(target.runProjectId, pendingFence.scope.accountScopeId);
795
+ } catch (_error) { /* Retain the fence if its durable cleanup is unconfirmed. */ }
796
+ }
797
+ return 'not-attempted';
593
798
  }
594
- if (invalidated && pageInvalidated) return 'not-attempted';
799
+ if (target.runProjectId && !pendingFence) await recordMirrorReuseFence(target);
800
+ if (invalidated && !pageInvalidated) failureReason = 'The page did not confirm snapshot invalidation.';
595
801
  const event = {
596
- title: tx('The local project cache could not be reset. Check the local connection before starting another task.',
802
+ title: options.undo === true ? tx(
803
+ `Overleaf undo finished, but local mirror invalidation failed: ${failureReason}. Refresh this project or reconnect the native host before running again.`,
804
+ `Overleaf 撤销已完成,但本地镜像失效处理失败:${failureReason}。请刷新项目或重新连接 Native Host 后再运行。`
805
+ ) : tx('The local project cache could not be reset. Check the local connection before starting another task.',
597
806
  '本地项目缓存未能重置。请检查本地连接后再开始下一轮任务。'),
598
807
  status: 'warning'
599
808
  };
@@ -638,6 +847,7 @@
638
847
  if (!Array.isArray(applied?.applied) || !applied.applied.length) {
639
848
  return 'not-attempted';
640
849
  }
850
+ const mirrorFence = await refreshMirrorReuseFence(target.runProjectId, target.runAccountScopeId);
641
851
 
642
852
  if (!confirmation.snapshot && await tryConfirmMirrorWriteback(applied, target.runProjectId)) {
643
853
  appendRunEvent({
@@ -689,6 +899,7 @@
689
899
  }
690
900
  });
691
901
  if (response?.ok) {
902
+ await settleFullMirrorSync(target.runProjectId, syncedProject, mirrorFence, response);
692
903
  appendRunEvent({
693
904
  title: tx('Local Codex workspace refreshed. The next run will start from the latest Overleaf content.', '已刷新本地 Codex workspace,下一轮会从最新 Overleaf 内容开始。'),
694
905
  status: 'completed'
@@ -916,6 +1127,8 @@
916
1127
  getCurrentRunView,
917
1128
  } = deps);
918
1129
  assetTransferBroker = deps.assetTransferBroker;
1130
+ mirrorFenceStore = deps.mirrorFenceStore;
1131
+ getAccountScopeId = deps.getAccountScopeId;
919
1132
  stageWritebackRecovery = deps.stageWritebackRecovery;
920
1133
  settleWritebackRecovery = deps.settleWritebackRecovery;
921
1134
  onMirrorRefreshSettled = deps.onMirrorRefreshSettled;
@@ -925,6 +1138,9 @@
925
1138
  applySyncChangesToOverleaf,
926
1139
  resolveCompileLogContext,
927
1140
  invalidateMirrorAfterUndo,
1141
+ getMirrorReuseFence,
1142
+ refreshMirrorReuseFence,
1143
+ syncFencedMirror,
928
1144
  getPendingMirrorRefresh: () => pendingMirrorRefresh
929
1145
  };
930
1146
  }