codex-overleaf-link 1.8.6 → 1.9.1

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 (73) hide show
  1. package/README.md +45 -21
  2. package/bin/codex-overleaf-link.mjs +58 -0
  3. package/extension/assets/icons/codex-overleaf-dialog-icon.png +0 -0
  4. package/extension/assets/icons/codex-overleaf-icon.png +0 -0
  5. package/extension/assets/icons/icon128.png +0 -0
  6. package/extension/assets/icons/icon16.png +0 -0
  7. package/extension/assets/icons/icon32.png +0 -0
  8. package/extension/assets/icons/icon48.png +0 -0
  9. package/extension/bootstrap/background.js +404 -0
  10. package/extension/bootstrap/manifest.template.json +51 -0
  11. package/extension/bootstrap/popup.css +26 -0
  12. package/extension/bootstrap/popup.html +38 -0
  13. package/extension/bootstrap/popup.js +44 -0
  14. package/extension/bootstrap/runtimeContext.js +2 -0
  15. package/extension/runtime-manifest.json +60 -0
  16. package/extension/src/background.js +532 -0
  17. package/extension/src/content/applyResultFormatters.js +387 -0
  18. package/extension/src/content/composerAttachments.js +522 -0
  19. package/extension/src/content/composerPanel.js +254 -0
  20. package/extension/src/content/contentRuntime.js +8897 -0
  21. package/extension/src/content/contextTray.js +686 -0
  22. package/extension/src/content/diagnosticsController.js +659 -0
  23. package/extension/src/content/diagnosticsPanel.js +342 -0
  24. package/extension/src/content/diffReviewPanel.js +797 -0
  25. package/extension/src/content/localSkillsPanel.js +280 -0
  26. package/extension/src/content/markdownText.js +606 -0
  27. package/extension/src/content/mirrorHealth.js +92 -0
  28. package/extension/src/content/modelPicker.js +489 -0
  29. package/extension/src/content/nativeChannel.js +62 -0
  30. package/extension/src/content/otWarmMirror.js +964 -0
  31. package/extension/src/content/otWarmMirrorController.js +174 -0
  32. package/extension/src/content/panelMaintenance.js +367 -0
  33. package/extension/src/content/panelRenderer.js +253 -0
  34. package/extension/src/content/recentProjects.js +1063 -0
  35. package/extension/src/content/reviewHunks.js +247 -0
  36. package/extension/src/content/runController.js +242 -0
  37. package/extension/src/content/runTimelineView.js +1131 -0
  38. package/extension/src/content/sessionManager.js +487 -0
  39. package/extension/src/content/sessionPanel.js +263 -0
  40. package/extension/src/content/settingsPanel.js +527 -0
  41. package/extension/src/content/themeController.js +91 -0
  42. package/extension/src/content/updateIdle.js +67 -0
  43. package/extension/src/content/writebackController.js +300 -0
  44. package/extension/src/content/writebackOrchestrator.js +850 -0
  45. package/extension/src/contentScript.js +8 -0
  46. package/extension/src/page/compileBridge.js +460 -0
  47. package/extension/src/page/overleafCapabilities.js +100 -0
  48. package/extension/src/page/overleafEditor.js +282 -0
  49. package/extension/src/page/overleafProjectSnapshot.js +234 -0
  50. package/extension/src/page/overleafRealtimeObserver.js +491 -0
  51. package/extension/src/page/pageBridgeCapability.js +71 -0
  52. package/extension/src/page/snapshotRouter.js +948 -0
  53. package/extension/src/page/trackedChangesLifecycle.js +1506 -0
  54. package/extension/src/page/treeOperations.js +1584 -0
  55. package/extension/src/page/writeGuard.js +142 -0
  56. package/extension/src/page/writebackRouter.js +1934 -0
  57. package/extension/src/pageBridge.js +2224 -0
  58. package/extension/src/popup.js +247 -0
  59. package/extension/src/shared/agentTranscript.js +18 -4
  60. package/extension/src/shared/compatibility.js +1 -1
  61. package/extension/styles/panel.css +4048 -0
  62. package/native-host/src/index.js +9 -2
  63. package/native-host/src/managedInstall.js +393 -0
  64. package/native-host/src/managedLauncherRuntime.js +71 -0
  65. package/native-host/src/nativeHostPlatform.js +16 -0
  66. package/native-host/src/runtimeInstaller.js +6 -1
  67. package/native-host/src/taskRunnerRuntime.js +8 -0
  68. package/native-host/src/updateArchive.js +253 -0
  69. package/native-host/src/updateManager.js +548 -0
  70. package/native-host/src/updateTrust.js +156 -0
  71. package/package.json +11 -2
  72. package/scripts/install-managed.mjs +64 -0
  73. package/scripts/uninstall-managed.mjs +48 -0
@@ -0,0 +1,1934 @@
1
+ (function initCodexOverleafWritebackRouter(root, factory) {
2
+ if (typeof module === 'object' && module.exports) {
3
+ module.exports = factory();
4
+ } else {
5
+ root.CodexOverleafWritebackRouter = factory();
6
+ }
7
+ })(typeof window !== 'undefined' ? window : globalThis, function writebackRouterFactory() {
8
+ 'use strict';
9
+
10
+ function create(deps = {}) {
11
+ const window = deps.window || (typeof globalThis !== 'undefined' ? globalThis : {});
12
+ const treeOperations = deps.treeOperations || {};
13
+ const compileBridge = deps.compileBridge || { markSourceEdited() {} };
14
+ const projectSnapshotBridge = deps.projectSnapshotBridge || {};
15
+ const normalizeSafeProjectPath = deps.normalizeSafeProjectPath || fallbackNormalizeSafeProjectPath;
16
+ const writebackOpenSettleMs = Number.isFinite(Number(deps.writebackOpenSettleMs))
17
+ ? Math.max(0, Number(deps.writebackOpenSettleMs))
18
+ : 1200;
19
+ const diagnosticsRevision = String(deps.diagnosticsRevision || '');
20
+ // Cross-world cancel signal reader. Returns the current page-bridge
21
+ // sequence number, which monotonically increments when content-side
22
+ // calls pageBridge.cancelActiveWrite. applyOperationsCore captures the
23
+ // baseline at start and re-reads between ops; any bump means the user
24
+ // cancelled and the remaining ops should be skipped with codex_cancelled.
25
+ const readWriteCancellationSequence = typeof deps.readWriteCancellationSequence === 'function'
26
+ ? deps.readWriteCancellationSequence
27
+ : () => 0;
28
+
29
+ function invalidProjectPathResult(label = 'path') {
30
+ return deps.invalidProjectPathResult?.(label) || {
31
+ ok: false,
32
+ code: 'invalid_project_path',
33
+ reason: 'Invalid ' + label + '.'
34
+ };
35
+ }
36
+
37
+ function ensureReviewing(params = {}) {
38
+ return deps.ensureReviewing?.(params) || Promise.resolve({ ok: false, reason: 'Reviewing controls are unavailable.' });
39
+ }
40
+
41
+ function ensureEditing(params = {}) {
42
+ return deps.ensureEditing?.(params) || Promise.resolve({ ok: false, reason: 'Editing controls are unavailable.' });
43
+ }
44
+
45
+ function getReviewingState(params = {}) {
46
+ return deps.getReviewingState?.(params) || { reviewing: { ok: false }, signals: {} };
47
+ }
48
+
49
+ function isEditingConfirmedForNoTraceUndo(state = {}) {
50
+ return deps.isEditingConfirmedForNoTraceUndo?.(state) === true;
51
+ }
52
+
53
+ // Authoritative "is Reviewing / Track Changes positively ON" check, wired to
54
+ // the page bridge's aria-aware isReviewingConfirmedForWrite. Unlike a bare
55
+ // reviewing.ok read this does not treat a control merely *labelled*
56
+ // "Reviewing" (with no active aria) as proof that Track Changes is on, so it
57
+ // is the correct strict gate for the Accept replay's Editing-mode switch.
58
+ function isReviewingConfirmedForWrite(state = {}) {
59
+ return deps.isReviewingConfirmedForWrite?.(state) === true;
60
+ }
61
+
62
+ function setReviewingEnabled(enabled, params = {}) {
63
+ return deps.setReviewingEnabled?.(enabled, params) || Promise.resolve({ ok: false, enabled });
64
+ }
65
+
66
+ function summarizeReviewingToggleResult(result = {}) {
67
+ return deps.summarizeReviewingToggleResult?.(result) || {
68
+ ok: result.ok === true,
69
+ changed: result.changed === true,
70
+ enabled: result.enabled === true,
71
+ code: result.code || '',
72
+ reason: result.reason || ''
73
+ };
74
+ }
75
+
76
+ function getActiveFilePath() {
77
+ return treeOperations.getActiveFilePath?.() || '';
78
+ }
79
+
80
+ function getActiveEditorIdentity() {
81
+ return deps.getActiveEditorIdentity?.() ?? null;
82
+ }
83
+
84
+ function activeEditorIdentityChanged(previous) {
85
+ // When no detector is wired (test/early-init), assume identity changed
86
+ // so callers re-run their per-op editor checks rather than silently
87
+ // reusing stale state.
88
+ return deps.activeEditorIdentityChanged
89
+ ? deps.activeEditorIdentityChanged(previous)
90
+ : true;
91
+ }
92
+
93
+ function openFileByPath(filePath, options = {}) {
94
+ return treeOperations.openFileByPath?.(filePath, options) || Promise.resolve({ ok: false, reason: 'Tree operations are unavailable' });
95
+ }
96
+
97
+ function projectPathExists(filePath) {
98
+ return treeOperations.projectPathExists?.(filePath) === true;
99
+ }
100
+
101
+ // Distinct from `projectPathExists` (which boolean-collapses an
102
+ // unavailable implementation): returns true only when the underlying
103
+ // treeOperations actually exposes a projectPathExists hook. Used by
104
+ // emit sites that only want to fire target_file_not_found when we have
105
+ // a real path-existence check — older test harnesses do not stub the
106
+ // hook, and treating "no hook" as "path missing" would break them.
107
+ function hasProjectPathExistsHook() {
108
+ return treeOperations.projectPathExists instanceof Function;
109
+ }
110
+
111
+ function findFileTreeManager() {
112
+ return treeOperations.findFileTreeManager?.() || null;
113
+ }
114
+
115
+ function readActiveEditorText() {
116
+ return deps.readActiveEditorText?.() || '';
117
+ }
118
+
119
+ function waitForActiveEditorText(filePath, timeoutMs, options = {}) {
120
+ if (treeOperations.waitForActiveEditorText) {
121
+ return treeOperations.waitForActiveEditorText(filePath, timeoutMs, options);
122
+ }
123
+ return Promise.resolve({
124
+ ok: true,
125
+ path: filePath || getActiveFilePath(),
126
+ text: readActiveEditorText()
127
+ });
128
+ }
129
+
130
+ function contentSignature(content) {
131
+ if (treeOperations.contentSignature) {
132
+ return treeOperations.contentSignature(content);
133
+ }
134
+ const text = String(content ?? '');
135
+ return `${text.length}:${text.slice(0, 80)}:${text.slice(-80)}`;
136
+ }
137
+
138
+ function contentFingerprint(content) {
139
+ const text = String(content ?? '');
140
+ let hash = 2166136261;
141
+ for (let index = 0; index < text.length; index += 1) {
142
+ hash ^= text.charCodeAt(index);
143
+ hash = Math.imul(hash, 16777619) >>> 0;
144
+ }
145
+ return {
146
+ length: text.length,
147
+ normalizedLength: normalizeEditorTextForComparison(text).length,
148
+ hash: hash.toString(16).padStart(8, '0'),
149
+ blank: text.trim().length === 0
150
+ };
151
+ }
152
+
153
+ function replaceActiveEditorText(text) {
154
+ return deps.replaceActiveEditorText?.(text) || { ok: false, reason: 'Editor adapter is unavailable' };
155
+ }
156
+
157
+ function replaceActiveEditorPatches(patches, nextContent) {
158
+ return deps.replaceActiveEditorPatches?.(patches, nextContent) || { ok: false, reason: 'Editor adapter is unavailable' };
159
+ }
160
+
161
+ function collectElements(selector, limit) {
162
+ return deps.collectElements?.(selector, limit) || [];
163
+ }
164
+
165
+ function uniqueNodes(nodes) {
166
+ return deps.uniqueNodes?.(nodes) || Array.from(new Set(nodes || []));
167
+ }
168
+
169
+ function readNodeSignalText(node) {
170
+ return deps.readNodeSignalText?.(node) || '';
171
+ }
172
+
173
+ function isInsideCodexPanel(node) {
174
+ return deps.isInsideCodexPanel?.(node) === true;
175
+ }
176
+
177
+ function normalizeReviewingSignalText(value) {
178
+ return deps.normalizeReviewingSignalText?.(value) || String(value || '').replace(/\s+/g, ' ').trim();
179
+ }
180
+
181
+ function clickNode(node) {
182
+ if (deps.clickNode) {
183
+ deps.clickNode(node);
184
+ return;
185
+ }
186
+ if (node?.click) {
187
+ node.click();
188
+ return;
189
+ }
190
+ node?.dispatchEvent?.(new MouseEvent('click', { bubbles: true, cancelable: true }));
191
+ }
192
+
193
+ function delay(ms) {
194
+ if (deps.delay) {
195
+ return deps.delay(ms);
196
+ }
197
+ return new Promise(resolve => window.setTimeout(resolve, ms));
198
+ }
199
+
200
+ function compact(value, limit) {
201
+ if (deps.compact) {
202
+ return deps.compact(value, limit);
203
+ }
204
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
205
+ return text.length > limit ? text.slice(0, limit - 3) + '...' : text;
206
+ }
207
+
208
+ function buildWritebackDebug(stage, operation, baseFileLookup, currentText, extra = {}) {
209
+ const filePath = normalizeSafeProjectPath(operation?.path || '');
210
+ const baseKnown = Boolean(baseFileLookup && filePath && baseFileLookup.has(filePath));
211
+ const baseContent = baseKnown ? baseFileLookup.get(filePath) : '';
212
+ return {
213
+ stage,
214
+ revision: diagnosticsRevision,
215
+ operationPath: filePath,
216
+ operationType: operation?.type || '',
217
+ activePath: getActiveFilePath(),
218
+ current: contentFingerprint(currentText),
219
+ baseKnown,
220
+ base: baseKnown ? contentFingerprint(baseContent) : null,
221
+ ...extra
222
+ };
223
+ }
224
+
225
+ function attachWritebackDebug(result, stage, operation, baseFileLookup, currentText, extra = {}) {
226
+ if (!result || typeof result !== 'object') {
227
+ return result;
228
+ }
229
+ return {
230
+ ...result,
231
+ debug: buildWritebackDebug(stage, operation, baseFileLookup, currentText, extra)
232
+ };
233
+ }
234
+
235
+ // Inline mirror of the page-side subset of the FailureReason §9 catalog used by
236
+ // T4 emit sites. Keeping it inline avoids an extra script-load dependency for
237
+ // the page-injected bundle; the catalog itself is duplicated in
238
+ // shared/failureReasons.js (FAILURE_CODE_CATALOG) — keep in sync when adding
239
+ // codes. Each entry mirrors stage / severity / defaultRetryable /
240
+ // fallbackUserMessage / fallbackNextAction.
241
+ const PAGE_FAILURE_CATALOG = {
242
+ target_file_not_found: {
243
+ stage: 'navigation', severity: 'blocked', defaultRetryable: true,
244
+ fallbackUserMessage: 'Codex could not find the target file in this Overleaf project.',
245
+ fallbackNextAction: 'Check the file name/path in Overleaf and retry.'
246
+ },
247
+ target_file_open_failed: {
248
+ stage: 'navigation', severity: 'blocked', defaultRetryable: true,
249
+ fallbackUserMessage: 'Codex could not open the target file in Overleaf.',
250
+ fallbackNextAction: 'Expand the folder or manually open the file, then retry.'
251
+ },
252
+ target_file_not_active: {
253
+ stage: 'navigation', severity: 'blocked', defaultRetryable: true,
254
+ fallbackUserMessage: 'Codex tried to write the target file, but another file was active at write time.',
255
+ fallbackNextAction: 'Open the target file in Overleaf, then retry this run.'
256
+ },
257
+ target_editor_not_ready: {
258
+ stage: 'navigation', severity: 'blocked', defaultRetryable: true,
259
+ fallbackUserMessage: 'Target file is active but the editor was not ready before timeout.',
260
+ fallbackNextAction: 'Wait for Overleaf to finish loading, then retry.'
261
+ },
262
+ stale_source_changed: {
263
+ stage: 'preflight', severity: 'blocked', defaultRetryable: true,
264
+ fallbackUserMessage: 'The file changed while Codex was working.',
265
+ fallbackNextAction: 'Review the current file, then rerun the task.'
266
+ },
267
+ patch_anchor_not_found: {
268
+ stage: 'preflight', severity: 'blocked', defaultRetryable: true,
269
+ fallbackUserMessage: 'The edit anchor no longer matches current Overleaf content.',
270
+ fallbackNextAction: 'Rerun the task against the current document.'
271
+ },
272
+ write_observed_mismatch: {
273
+ stage: 'verify', severity: 'error', defaultRetryable: false,
274
+ fallbackUserMessage: 'Codex attempted to write, but the content read back from Overleaf did not match the approved change.',
275
+ fallbackNextAction: 'Open Technical Details and compare expected vs observed.'
276
+ },
277
+ editing_not_confirmed: {
278
+ stage: 'reviewing', severity: 'blocked', defaultRetryable: true,
279
+ fallbackUserMessage: 'Editing mode could not be proven stable.',
280
+ fallbackNextAction: 'Do not write; check the mode selector and retry.'
281
+ },
282
+ tracked_changes_remain: {
283
+ stage: 'reviewing', severity: 'warning', defaultRetryable: true,
284
+ fallbackUserMessage: 'Accept/reject operation finished but tracked changes remain.',
285
+ fallbackNextAction: 'Open Overleaf Reviewing and inspect remaining changes.'
286
+ }
287
+ };
288
+
289
+ // Build a structured FailureReason record (§7) for emit at the page layer.
290
+ // `overrides` merges into the entry: callers supply `file` / `activeFile` /
291
+ // `userMessage` / `evidence` / `changedDocument` / `terminalState` etc.
292
+ function buildPageFailure(code, overrides) {
293
+ const entry = PAGE_FAILURE_CATALOG[code];
294
+ if (!entry) {
295
+ return null;
296
+ }
297
+ const merged = overrides || {};
298
+ const failure = {
299
+ code,
300
+ stage: entry.stage,
301
+ severity: entry.severity,
302
+ userMessage: merged.userMessage || entry.fallbackUserMessage,
303
+ retryable: merged.retryable === undefined ? entry.defaultRetryable : merged.retryable === true,
304
+ nextAction: merged.nextAction || entry.fallbackNextAction
305
+ };
306
+ if (merged.file !== undefined) failure.file = merged.file;
307
+ if (merged.activeFile !== undefined) failure.activeFile = merged.activeFile;
308
+ if (merged.operationType !== undefined) failure.operationType = merged.operationType;
309
+ if (merged.changedDocument !== undefined) failure.changedDocument = merged.changedDocument === true;
310
+ if (merged.terminalState !== undefined) failure.terminalState = merged.terminalState;
311
+ if (merged.technicalMessage !== undefined) failure.technicalMessage = merged.technicalMessage;
312
+ if (merged.evidence !== undefined) failure.evidence = merged.evidence;
313
+ return failure;
314
+ }
315
+
316
+ function normalizeTextPatches(patches, length) {
317
+ if (deps.normalizeTextPatches) {
318
+ return deps.normalizeTextPatches(patches, length);
319
+ }
320
+ const normalized = [];
321
+ let previousTo = 0;
322
+ for (const rawPatch of patches || []) {
323
+ const from = Number(rawPatch?.from);
324
+ const to = Number(rawPatch?.to);
325
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > length) {
326
+ return {
327
+ ok: false,
328
+ code: 'invalid_patch',
329
+ reason: 'Codex generated an invalid patch range.'
330
+ };
331
+ }
332
+ if (from < previousTo) {
333
+ return {
334
+ ok: false,
335
+ code: 'invalid_patch',
336
+ reason: 'Codex generated overlapping patch ranges.'
337
+ };
338
+ }
339
+ previousTo = to;
340
+ normalized.push({
341
+ from,
342
+ to,
343
+ expected: String(rawPatch.expected ?? ''),
344
+ insert: String(rawPatch.insert ?? '')
345
+ });
346
+ }
347
+ return {
348
+ ok: true,
349
+ patches: normalized
350
+ };
351
+ }
352
+
353
+ async function applyOperations(operations, options = {}) {
354
+ if (options.reviewingPolicy === 'no-trace-undo') {
355
+ return applyOperationsWithNoTraceUndo(operations, options);
356
+ }
357
+ const hasOperations = (operations || []).length > 0;
358
+ const trackReviewingChanges = options.requireReviewing === true && hasOperations;
359
+ let reviewingPolicy = null;
360
+ if (options.requireReviewing === true && hasOperations) {
361
+ const reviewing = await ensureReviewing({ waitMs: 1800 });
362
+ if (!reviewing.ok) {
363
+ return buildReviewingRequiredBlockedResult(operations, reviewing);
364
+ }
365
+ } else if (options.requireEditing === true && hasOperations) {
366
+ const editing = await ensureEditing({ waitMs: 1800 });
367
+ if (!editing.ok) {
368
+ return buildEditingRequiredBlockedResult(operations, editing);
369
+ }
370
+ reviewingPolicy = {
371
+ policy: 'editing-write',
372
+ disabled: editing.activated === true,
373
+ leftEditing: true,
374
+ reason: editing.activated ? 'switched_to_editing_before_write' : 'editing_already_confirmed',
375
+ disable: summarizeReviewingToggleResult({
376
+ ok: true,
377
+ changed: editing.activated === true,
378
+ enabled: false,
379
+ reason: editing.activated ? 'Switched to Editing before untracked write.' : 'Editing already confirmed before untracked write.'
380
+ })
381
+ };
382
+ }
383
+ const result = await applyOperationsCore(operations, {
384
+ ...options,
385
+ trackReviewingChanges
386
+ });
387
+ return reviewingPolicy
388
+ ? {
389
+ ...result,
390
+ reviewingPolicy
391
+ }
392
+ : result;
393
+ }
394
+
395
+ async function applyOperationsWithNoTraceUndo(operations, options = {}) {
396
+ const initial = getReviewingState({});
397
+ if (isEditingConfirmedForNoTraceUndo(initial)) {
398
+ const result = await applyOperationsCore(operations, options);
399
+ return {
400
+ ...result,
401
+ reviewingPolicy: {
402
+ policy: 'no-trace-undo',
403
+ disabled: false,
404
+ restored: false,
405
+ reason: 'editing_already_confirmed'
406
+ }
407
+ };
408
+ }
409
+
410
+ const disabled = await setReviewingEnabled(false, { waitMs: 1800 });
411
+ if (!disabled.ok) {
412
+ return buildNoTraceUndoBlockedResult(operations, disabled);
413
+ }
414
+
415
+ let result;
416
+ let applyError = null;
417
+ try {
418
+ result = await applyOperationsCore(operations, options);
419
+ } catch (error) {
420
+ applyError = error;
421
+ }
422
+ if (applyError) {
423
+ throw applyError;
424
+ }
425
+
426
+ return {
427
+ ...result,
428
+ reviewingPolicy: {
429
+ policy: 'no-trace-undo',
430
+ disabled: true,
431
+ restored: false,
432
+ leftEditing: true,
433
+ reason: 'left_editing_after_undo',
434
+ disable: summarizeReviewingToggleResult(disabled)
435
+ }
436
+ };
437
+ }
438
+
439
+ async function applyOperationsCore(operations, options = {}) {
440
+ const applied = [];
441
+ const skipped = [];
442
+ const trackedChanges = [];
443
+ const safeBaseFiles = normalizeBaseFilesForSafety(options.baseFiles);
444
+ const baseFileLookup = window.CodexOverleafStaleGuard?.buildBaseFileLookup(safeBaseFiles);
445
+ const baseBinaryFileLookup = buildBaseBinaryFileLookup(safeBaseFiles);
446
+ // Mid-run project-change guard. The pageBridge wrapper already ran the
447
+ // writeGuard once at entry, but each per-op step here can take 0.5-5s
448
+ // (openFileByPath, waitForActiveEditorText, save-state verification),
449
+ // so a multi-op write can span 10-30 seconds total. If the user SPA-
450
+ // navigates to a different project mid-flight, the remaining operations
451
+ // would land in the wrong project. Re-check the editor project before
452
+ // each op and short-circuit the rest of the queue when it diverges.
453
+ const runProjectId = typeof options.runProjectId === 'string' ? options.runProjectId : '';
454
+ const writeGuardSurface = window.CodexOverleafWriteGuard?.create({
455
+ window,
456
+ document: window.document,
457
+ treeOperations
458
+ });
459
+ // Cross-world cancel baseline. The user clicks cancel during writeback →
460
+ // content-side calls pageBridge.cancelActiveWrite → the sequence number
461
+ // bumps. Two layers of responsiveness:
462
+ // 1. Between-ops check — short-circuits the next iteration so the
463
+ // remaining ops never start.
464
+ // 2. Race the in-flight op against a cancellation poller (50ms
465
+ // interval) so a click DURING a slow op (e.g. inside a 5s
466
+ // waitForActiveFile poll) is observed within ~50ms instead of
467
+ // having to wait out the op's internal timeout. The op promise
468
+ // keeps running in the page world after the race resolves — its
469
+ // eventual result is ignored. This is "user-perceived instant
470
+ // cancel": the spinner stops, the run settles to cancelled, and
471
+ // any DOM mutation already in flight on the op cannot be undone
472
+ // from here anyway.
473
+ const cancelBaselineSequence = readWriteCancellationSequence();
474
+ const cancelledSkipResult = {
475
+ ok: false,
476
+ code: 'codex_cancelled',
477
+ reason: 'The Codex run was cancelled by the user while the writeback was in flight; remaining operations were not applied.',
478
+ failure: {
479
+ code: 'codex_run_cancelled',
480
+ stage: 'write',
481
+ severity: 'info',
482
+ userMessage: 'The Codex run was cancelled by the user during writeback; remaining file operations were skipped.',
483
+ nextAction: 'Start a new run if you want to apply these changes.',
484
+ retryable: true,
485
+ terminalState: 'cancelled',
486
+ changedDocument: false
487
+ }
488
+ };
489
+ const cancelledInFlightResult = {
490
+ ...cancelledSkipResult,
491
+ reason: 'The Codex run was cancelled while an Overleaf write operation was already in flight. The editor may have changed; review the current file before starting a new run.',
492
+ failure: {
493
+ ...cancelledSkipResult.failure,
494
+ severity: 'warning',
495
+ userMessage: 'The Codex run was cancelled during an in-flight write. The editor may already contain part of that write.',
496
+ nextAction: 'Review the current Overleaf file before starting a new run.',
497
+ changedDocument: true
498
+ }
499
+ };
500
+ // 50ms-poller racer using recursive setTimeout. setInterval is NOT
501
+ // exposed in the test VM context that hosts pageBridge (only setTimeout /
502
+ // clearTimeout are wired), so this implementation must avoid it. Each
503
+ // tick re-schedules itself only while the racer has not resolved;
504
+ // dispose() flips a flag so any tick that fires after dispose is a no-op.
505
+ // Resolves the first time the cancellation sequence bumps past baseline.
506
+ function createCancellationRacer() {
507
+ let resolved = false;
508
+ let resolveFn;
509
+ const promise = new Promise(resolve => { resolveFn = resolve; });
510
+ let timer = null;
511
+ function tick() {
512
+ if (resolved) return;
513
+ if (readWriteCancellationSequence() !== cancelBaselineSequence) {
514
+ resolved = true;
515
+ resolveFn();
516
+ return;
517
+ }
518
+ timer = window.setTimeout(tick, 50);
519
+ }
520
+ timer = window.setTimeout(tick, 50);
521
+ return {
522
+ promise,
523
+ dispose() {
524
+ resolved = true;
525
+ if (timer) window.clearTimeout(timer);
526
+ }
527
+ };
528
+ }
529
+ const CANCELLED_RACE_SENTINEL = Symbol('writebackRouter.cancelled');
530
+ async function raceOpAgainstCancellation(opPromise) {
531
+ const racer = createCancellationRacer();
532
+ try {
533
+ return await Promise.race([
534
+ opPromise,
535
+ racer.promise.then(() => CANCELLED_RACE_SENTINEL)
536
+ ]);
537
+ } finally {
538
+ racer.dispose();
539
+ }
540
+ }
541
+
542
+ for (const rawOperation of operations) {
543
+ // Cross-world cancel check. Cheap (synchronous read of a counter),
544
+ // runs before the writeGuard re-check so a click-cancel that arrived
545
+ // during the previous op's await microtasks short-circuits before we
546
+ // burn time on guard / open-file work for the next op.
547
+ if (readWriteCancellationSequence() !== cancelBaselineSequence) {
548
+ skipped.push({ operation: normalizeOperationPaths(rawOperation), result: cancelledSkipResult });
549
+ const remainingForCancel = operations.slice(operations.indexOf(rawOperation) + 1);
550
+ for (const tailOperation of remainingForCancel) {
551
+ skipped.push({ operation: normalizeOperationPaths(tailOperation), result: cancelledSkipResult });
552
+ }
553
+ break;
554
+ }
555
+ // Per-op re-check: if a runProjectId was supplied and the page-side
556
+ // guard is available, verify the editor still shows the same project
557
+ // before committing the next write. Mismatch → push aborted_project_changed
558
+ // for THIS op and every remaining op in the queue, then return.
559
+ if (runProjectId && writeGuardSurface) {
560
+ const guardBlock = await writeGuardSurface.runWriteGuard({ runProjectId });
561
+ if (guardBlock) {
562
+ // First push the guard's structured skip for the current op so the
563
+ // caller can attribute the abort to a real operation path, then
564
+ // pad the remaining ops with the same failure shape.
565
+ const guardFailure = guardBlock.skipped[0].result;
566
+ skipped.push({ operation: normalizeOperationPaths(rawOperation), result: guardFailure });
567
+ const remaining = operations.slice(operations.indexOf(rawOperation) + 1);
568
+ for (const tailOperation of remaining) {
569
+ skipped.push({ operation: normalizeOperationPaths(tailOperation), result: guardFailure });
570
+ }
571
+ break;
572
+ }
573
+ }
574
+ const operation = normalizeOperationPaths(rawOperation);
575
+ const pathSafety = validateOperationProjectPaths(operation);
576
+ if (!pathSafety.ok) {
577
+ skipped.push({ operation, result: pathSafety });
578
+ continue;
579
+ }
580
+ let raceResult;
581
+ if (operation.type === 'edit') {
582
+ raceResult = await raceOpAgainstCancellation(applyEditOperation(operation, {
583
+ baseFileLookup,
584
+ trackReviewingChanges: options.trackReviewingChanges === true
585
+ }));
586
+ } else if (['binary-create', 'overwrite-binary'].includes(operation.type)) {
587
+ raceResult = await raceOpAgainstCancellation(applyBinaryAssetOperation(operation, { baseFileLookup, baseBinaryFileLookup }));
588
+ } else if (['create', 'rename', 'move', 'delete'].includes(operation.type)) {
589
+ raceResult = await raceOpAgainstCancellation(applyFileTreeOperation(operation, { baseFileLookup }));
590
+ } else {
591
+ skipped.push({
592
+ operation,
593
+ result: {
594
+ ok: false,
595
+ reason: `Unsupported operation type: ${operation.type}`
596
+ }
597
+ });
598
+ continue;
599
+ }
600
+ // Cancellation race winner: skip current op + the rest of the queue.
601
+ // The op promise above keeps running in background; its eventual
602
+ // result is discarded. The user perceives instant cancel because we
603
+ // settle the loop here instead of waiting for the op to finish.
604
+ if (raceResult === CANCELLED_RACE_SENTINEL) {
605
+ skipped.push({ operation, result: cancelledInFlightResult });
606
+ const remainingAfterCancel = operations.slice(operations.indexOf(rawOperation) + 1);
607
+ for (const tailOperation of remainingAfterCancel) {
608
+ skipped.push({ operation: normalizeOperationPaths(tailOperation), result: cancelledSkipResult });
609
+ }
610
+ break;
611
+ }
612
+ const result = raceResult;
613
+ if (operation.type === 'edit' && result.ok && Array.isArray(result.trackedChanges)) {
614
+ trackedChanges.push(...result.trackedChanges);
615
+ }
616
+ (result.ok ? applied : skipped).push({ operation, result });
617
+ }
618
+
619
+ if (applied.length > 0) {
620
+ compileBridge.markSourceEdited();
621
+ }
622
+
623
+ return {
624
+ ok: skipped.length === 0,
625
+ applied,
626
+ skipped,
627
+ trackedChanges: mergeTrackedChangeRefs(trackedChanges)
628
+ };
629
+ }
630
+
631
+
632
+
633
+
634
+
635
+
636
+
637
+
638
+
639
+
640
+
641
+
642
+
643
+
644
+
645
+
646
+
647
+
648
+
649
+
650
+
651
+
652
+
653
+
654
+
655
+ async function applyEditOperation(operation, options = {}) {
656
+ const currentPath = getActiveFilePath();
657
+ const editorReady = await ensureEditorReadyForOperation(operation, options.baseFileLookup, currentPath);
658
+ if (!editorReady.ok) {
659
+ return editorReady;
660
+ }
661
+
662
+ const trackReviewingChanges = options.trackReviewingChanges === true;
663
+ const forbidTrackedChanges = options.forbidTrackedChanges === true;
664
+ const observeTrackedChanges = trackReviewingChanges || forbidTrackedChanges;
665
+ const operationPaths = collectOperationPaths([operation]);
666
+ const trackedBefore = observeTrackedChanges
667
+ ? collectTrackedChangeRefsForPaths(operationPaths)
668
+ : [];
669
+ let current = editorReady.text;
670
+ let freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
671
+ operation,
672
+ current,
673
+ options.baseFileLookup
674
+ ) || { ok: true };
675
+ if (!freshness.ok) {
676
+ const ready = await waitForFreshEditorTextForOperation(operation, options.baseFileLookup, 1500);
677
+ if (!ready.ok) {
678
+ const decorated = attachWritebackDebug(freshness, 'stale_guard', operation, options.baseFileLookup, current, {
679
+ initialActivePath: currentPath,
680
+ editorReadyDebug: editorReady.debug || null,
681
+ waitFresh: ready
682
+ });
683
+ // §9.2: legacy stale_snapshot / stale_patch_range / missing_base_file
684
+ // map to canonical preflight failure codes. The structured failure
685
+ // attaches the per-emit-site evidence so the run card / final report
686
+ // can render with file context without round-tripping through the
687
+ // normalizer's legacy mapping.
688
+ const staleCode = freshness.code === 'missing_base_file'
689
+ ? 'missing_base_file'
690
+ : freshness.code === 'stale_patch_range'
691
+ ? 'patch_anchor_not_found'
692
+ : 'stale_source_changed';
693
+ if (staleCode === 'stale_source_changed' || staleCode === 'patch_anchor_not_found') {
694
+ decorated.failure = buildPageFailure(staleCode, {
695
+ file: operation?.path || '',
696
+ operationType: operation?.type || 'edit',
697
+ changedDocument: false,
698
+ userMessage: staleCode === 'stale_source_changed'
699
+ ? `${operation?.path || 'The target file'} changed while Codex was working, so Codex did not overwrite it.`
700
+ : `${operation?.path || 'The target file'} no longer contains the text Codex expected to edit, so Codex did not overwrite it.`,
701
+ technicalMessage: freshness.reason || '',
702
+ evidence: {
703
+ originalCode: freshness.code || '',
704
+ baselineMatched: false,
705
+ writeStarted: false
706
+ }
707
+ });
708
+ }
709
+ return decorated;
710
+ }
711
+ current = ready.text;
712
+ freshness = { ok: true };
713
+ }
714
+
715
+ let nextContent = null;
716
+ if (Array.isArray(operation.patches) && operation.patches.length) {
717
+ const patched = applyTextPatches(current, operation.patches);
718
+ if (!patched.ok) {
719
+ const decorated = attachWritebackDebug(patched, 'apply_text_patches', operation, options.baseFileLookup, current, {
720
+ initialActivePath: currentPath,
721
+ editorReadyDebug: editorReady.debug || null
722
+ });
723
+ // §9.2: stale_patch (a patch `expected` slice did not match the
724
+ // current document) is the canonical patch_anchor_not_found case.
725
+ // invalid_patch (range out of bounds / overlapping) also belongs to
726
+ // patch_anchor_not_found per §9.2: the anchor cannot be aligned to
727
+ // current content.
728
+ if (patched.code === 'stale_patch' || patched.code === 'invalid_patch') {
729
+ decorated.failure = buildPageFailure('patch_anchor_not_found', {
730
+ file: operation?.path || '',
731
+ operationType: operation?.type || 'edit',
732
+ changedDocument: false,
733
+ userMessage: `Codex's edit anchors no longer match ${operation?.path || 'the current document'}, so Codex did not overwrite it.`,
734
+ technicalMessage: patched.reason || '',
735
+ evidence: {
736
+ originalCode: patched.code || '',
737
+ writeStarted: false
738
+ }
739
+ });
740
+ }
741
+ return decorated;
742
+ }
743
+ nextContent = patched.text;
744
+ } else if (typeof operation.replaceAll === 'string') {
745
+ nextContent = operation.replaceAll;
746
+ } else if (typeof operation.find === 'string' && typeof operation.replace === 'string') {
747
+ if (!current.includes(operation.find)) {
748
+ return {
749
+ ok: false,
750
+ reason: 'Find text was not present in the active editor'
751
+ };
752
+ }
753
+ nextContent = current.split(operation.find).join(operation.replace);
754
+ } else {
755
+ return {
756
+ ok: false,
757
+ reason: 'Edit operation must provide patches, replaceAll, or find/replace fields'
758
+ };
759
+ }
760
+
761
+ const result = Array.isArray(operation.patches) && operation.patches.length
762
+ ? replaceActiveEditorPatches(operation.patches, nextContent)
763
+ : replaceActiveEditorText(nextContent);
764
+ if (!result.ok) {
765
+ return result;
766
+ }
767
+
768
+ const verified = await verifyActiveEditorText(nextContent, operation.path);
769
+ if (!verified.ok) {
770
+ const decorated = attachWritebackDebug(verified, 'write_verification', operation, options.baseFileLookup, readActiveEditorText(), {
771
+ initialActivePath: currentPath,
772
+ editorReadyDebug: editorReady.debug || null
773
+ });
774
+ // §9.3 write_observed_mismatch: the write call landed but the readback
775
+ // does not match. changedDocument:true — the editor state moved, just
776
+ // not as Codex expected.
777
+ decorated.failure = buildPageFailure('write_observed_mismatch', {
778
+ file: operation?.path || '',
779
+ operationType: operation?.type || 'edit',
780
+ changedDocument: true,
781
+ userMessage: `${operation?.path || 'The target file'} did not read back the content Codex wrote; the document state may differ from what was approved.`,
782
+ technicalMessage: verified.reason || '',
783
+ evidence: {
784
+ originalCode: verified.code || '',
785
+ expectedLength: verified.expectedLength,
786
+ actualLength: verified.actualLength,
787
+ writeStarted: true
788
+ }
789
+ });
790
+ return decorated;
791
+ }
792
+ const trackedChanges = [];
793
+ if (observeTrackedChanges) {
794
+ const trackedDiff = forbidTrackedChanges
795
+ ? await waitForTrackedChangeDiff(trackedBefore, operationPaths, {
796
+ waitMs: 3600,
797
+ intervalMs: 180
798
+ })
799
+ : (() => null)();
800
+ if (trackedDiff) {
801
+ trackedChanges.push(...trackedDiff.trackedChanges);
802
+ } else {
803
+ await delay(120);
804
+ const trackedAfter = collectTrackedChangeRefsForPaths(operationPaths);
805
+ trackedChanges.push(...diffTrackedChangeRefs(trackedBefore, trackedAfter));
806
+ }
807
+ if (forbidTrackedChanges && trackedChanges.length > 0) {
808
+ return {
809
+ ok: false,
810
+ code: 'accept_replay_created_tracked_changes',
811
+ reason: 'Accept All replay wrote the expected text, but Overleaf created fresh tracked changes during the replay. Codex tried to roll back this replay and left the run pending instead of marking it accepted.',
812
+ // §9.4 tracked_changes_remain: warning severity, needs_review terminal
813
+ // state. The replay wrote (changedDocument:true), but Overleaf left
814
+ // tracked-change nodes behind, so Accept cannot be proven clean.
815
+ failure: buildPageFailure('tracked_changes_remain', {
816
+ file: operation?.path || '',
817
+ operationType: operation?.type || 'edit',
818
+ changedDocument: true,
819
+ terminalState: 'needs_review',
820
+ userMessage: `${operation?.path || 'The target file'} still has tracked changes after the Accept All replay, so the run could not be marked accepted.`,
821
+ evidence: {
822
+ originalCode: 'accept_replay_created_tracked_changes',
823
+ trackedChangeCount: trackedChanges.length,
824
+ writeStarted: true
825
+ }
826
+ }),
827
+ verified: true,
828
+ verifiedContent: nextContent,
829
+ trackedChanges: mergeTrackedChangeRefs(trackedChanges)
830
+ };
831
+ }
832
+ }
833
+ window.CodexOverleafStaleGuard?.updateExpectedFileContent(
834
+ options.baseFileLookup,
835
+ operation.path,
836
+ nextContent
837
+ );
838
+ return {
839
+ ...result,
840
+ verified: true,
841
+ verifiedContent: nextContent,
842
+ trackedChanges: mergeTrackedChangeRefs(trackedChanges)
843
+ };
844
+ }
845
+
846
+
847
+ async function verifyActiveEditorText(expected, filePath, waitMs = 1000) {
848
+ const deadline = Date.now() + waitMs;
849
+ let actual = readActiveEditorText();
850
+ while (actual !== expected && Date.now() < deadline) {
851
+ await delay(50);
852
+ actual = readActiveEditorText();
853
+ }
854
+ if (actual === expected) {
855
+ return {
856
+ ok: true
857
+ };
858
+ }
859
+ return {
860
+ ok: false,
861
+ code: 'write_verification_failed',
862
+ reason: `${filePath || '当前文件'} 写入后读回内容和 Codex 预期不一致,已停止把这次操作标记为成功。请刷新 Overleaf 后重试。`,
863
+ expectedLength: String(expected || '').length,
864
+ actualLength: String(actual || '').length
865
+ };
866
+ }
867
+
868
+ async function ensureEditorReadyForOperation(operation, baseFileLookup, initialActivePath) {
869
+ if (!operation?.path) {
870
+ return {
871
+ ok: true,
872
+ text: readActiveEditorText()
873
+ };
874
+ }
875
+ const filePath = normalizeSafeProjectPath(operation.path);
876
+ if (!filePath) {
877
+ return invalidProjectPathResult('operation path');
878
+ }
879
+
880
+ const currentText = readActiveEditorText();
881
+ const previousEditorIdentity = getActiveEditorIdentity();
882
+ const previousSignature = contentSignature(currentText);
883
+
884
+ // §9.1 target_file_not_found: if the target path is not visible in the
885
+ // Overleaf project file tree, refuse the write before attempting to open.
886
+ // openFileByPath itself would fail anyway, but emitting the canonical
887
+ // path-not-found code (rather than a generic open failure) gives the run
888
+ // card / final report a more actionable presentation per §17.3.1.
889
+ // Only fires when the tree-operations layer actually exposes a
890
+ // projectPathExists hook; older harnesses without the hook fall through
891
+ // to the openFileByPath path and surface a generic open failure.
892
+ if (operation?.type === 'edit' && hasProjectPathExistsHook() && !projectPathExists(filePath)) {
893
+ return {
894
+ ok: false,
895
+ code: 'path_not_found',
896
+ reason: `Cannot edit ${filePath}; the path is not in the Overleaf project file tree.`,
897
+ failure: buildPageFailure('target_file_not_found', {
898
+ file: filePath,
899
+ activeFile: initialActivePath || '',
900
+ operationType: operation?.type || 'edit',
901
+ changedDocument: false,
902
+ userMessage: `Codex could not find ${filePath} in this Overleaf project.`,
903
+ evidence: {
904
+ originalCode: 'path_not_found',
905
+ initialActivePath: initialActivePath || '',
906
+ writeStarted: false
907
+ }
908
+ })
909
+ };
910
+ }
911
+
912
+ const opened = await openFileByPath(filePath, { force: initialActivePath === filePath });
913
+ if (!opened.ok) {
914
+ const reasonText = `Cannot edit ${filePath}; active file is ${initialActivePath || 'unknown'}; ${opened.reason}`;
915
+ return {
916
+ ok: false,
917
+ code: 'file_open_failed',
918
+ debug: buildWritebackDebug('open_file_failed', operation, baseFileLookup, currentText, {
919
+ initialActivePath,
920
+ opened
921
+ }),
922
+ reason: reasonText,
923
+ failure: buildPageFailure('target_file_open_failed', {
924
+ file: filePath,
925
+ activeFile: initialActivePath || '',
926
+ operationType: operation?.type || 'edit',
927
+ changedDocument: false,
928
+ userMessage: `Codex could not open ${filePath} in Overleaf.`,
929
+ technicalMessage: opened.reason || '',
930
+ evidence: {
931
+ initialActivePath: initialActivePath || '',
932
+ openMethod: opened.method || '',
933
+ writeStarted: false
934
+ }
935
+ })
936
+ };
937
+ }
938
+
939
+ return waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, 9000, {
940
+ initialActivePath,
941
+ previousSignature,
942
+ forceSettledOpen: true,
943
+ openedMethod: opened.method || ''
944
+ });
945
+ }
946
+
947
+ async function waitForEditorContentForPath(filePath, previousEditorIdentity, baseFileLookup, timeoutMs, options = {}) {
948
+ const deadline = Date.now() + timeoutMs;
949
+ const startedAt = Date.now();
950
+ const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
951
+ let lastText = readActiveEditorText();
952
+ let lastActivePath = getActiveFilePath();
953
+ while (Date.now() < deadline) {
954
+ lastActivePath = getActiveFilePath();
955
+ lastText = readActiveEditorText();
956
+ const activeFileMatches = lastActivePath === filePath;
957
+ const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
958
+ const baseComparison = compareEditorTextToBase(filePath, lastText, baseFileLookup);
959
+ const baseMatches = baseComparison.matches;
960
+ const settledLongEnough = Date.now() - startedAt >= minSettleMs;
961
+ const contentChangedFromPrevious = !options.previousSignature
962
+ || contentSignature(lastText) !== options.previousSignature;
963
+ // Switch confirmation must not depend on any single fragile signal
964
+ // (v1.6: identity comparison proved unreliable against newer Overleaf
965
+ // view lifecycles, which made every write to a non-active file time
966
+ // out as target_editor_not_ready). Acceptors, strongest first:
967
+ // 1. editor identity visibly changed (fast path when it works);
968
+ // 2. the editor shows EXACTLY the target file's expected base —
969
+ // the strongest possible proof the right document is loaded;
970
+ // 3. same-file reopen settled with unchanged content;
971
+ // 4. base UNKNOWN only: the content moved off the previous document
972
+ // and the settle window elapsed. When the base IS known, content
973
+ // must match it (acceptor 2) — a weakly-anchored patch (e.g. an
974
+ // insert into an empty file, expected:'') would otherwise anchor
975
+ // trivially into a wrong-but-different document.
976
+ const switchConfirmed = identityChanged
977
+ || baseMatches
978
+ || (options.initialActivePath === filePath
979
+ && options.previousSignature
980
+ && contentSignature(lastText) === options.previousSignature
981
+ && settledLongEnough)
982
+ || (!baseComparison.known && contentChangedFromPrevious && settledLongEnough);
983
+ const contentNoLongerLooksLikePreviousDocument = contentChangedFromPrevious
984
+ || baseMatches
985
+ || options.initialActivePath === filePath;
986
+ if (activeFileMatches && switchConfirmed && settledLongEnough && contentNoLongerLooksLikePreviousDocument) {
987
+ return {
988
+ ok: true,
989
+ text: lastText
990
+ };
991
+ }
992
+ await delay(100);
993
+ }
994
+ // §9.1: if the active file ended up on a different document, this is
995
+ // target_file_not_active; if the file did become active but the editor
996
+ // document never settled, this is target_editor_not_ready.
997
+ const activeMismatched = Boolean(lastActivePath) && lastActivePath !== filePath;
998
+ const canonicalCode = activeMismatched ? 'target_file_not_active' : 'target_editor_not_ready';
999
+ return {
1000
+ ok: false,
1001
+ code: 'editor_document_not_switched',
1002
+ reason: `Cannot edit ${filePath}; Overleaf selected ${lastActivePath || 'unknown file'} but the editor document did not load the target file. Codex did not write to avoid editing the wrong file.`,
1003
+ lastActivePath,
1004
+ lastLength: String(lastText || '').length,
1005
+ debug: buildWritebackDebug('editor_document_not_switched', { type: 'edit', path: filePath }, baseFileLookup, lastText, {
1006
+ initialActivePath: options.initialActivePath || '',
1007
+ lastActivePath,
1008
+ previous: options.previousSignature || '',
1009
+ currentSignature: contentSignature(lastText),
1010
+ openedMethod: options.openedMethod || '',
1011
+ elapsedMs: Date.now() - startedAt
1012
+ }),
1013
+ failure: buildPageFailure(canonicalCode, {
1014
+ file: filePath,
1015
+ activeFile: lastActivePath || '',
1016
+ operationType: 'edit',
1017
+ changedDocument: false,
1018
+ userMessage: activeMismatched
1019
+ ? `Codex tried to write ${filePath}, but ${lastActivePath || 'a different file'} was active at write time.`
1020
+ : `${filePath} became active in Overleaf, but the editor was not ready before timeout.`,
1021
+ evidence: {
1022
+ initialActivePath: options.initialActivePath || '',
1023
+ lastActivePath: lastActivePath || '',
1024
+ openedMethod: options.openedMethod || '',
1025
+ elapsedMs: Date.now() - startedAt,
1026
+ writeStarted: false
1027
+ }
1028
+ })
1029
+ };
1030
+ }
1031
+
1032
+ async function waitForEditorDocumentForPath(filePath, previousEditorIdentity, timeoutMs, options = {}) {
1033
+ const deadline = Date.now() + timeoutMs;
1034
+ const startedAt = Date.now();
1035
+ const minSettleMs = options.forceSettledOpen ? writebackOpenSettleMs : 0;
1036
+ let lastText = readActiveEditorText();
1037
+ let lastActivePath = getActiveFilePath();
1038
+ while (Date.now() < deadline) {
1039
+ lastActivePath = getActiveFilePath();
1040
+ lastText = readActiveEditorText();
1041
+ const activeFileMatches = lastActivePath === filePath;
1042
+ const identityChanged = Boolean(previousEditorIdentity) && activeEditorIdentityChanged(previousEditorIdentity);
1043
+ const settledLongEnough = Date.now() - startedAt >= minSettleMs;
1044
+ const sameFileReopenSettled = options.initialActivePath === filePath && settledLongEnough;
1045
+ if (activeFileMatches && settledLongEnough && (identityChanged || sameFileReopenSettled)) {
1046
+ return {
1047
+ ok: true,
1048
+ text: lastText
1049
+ };
1050
+ }
1051
+ await delay(100);
1052
+ }
1053
+ return {
1054
+ ok: false,
1055
+ code: 'editor_document_not_switched',
1056
+ reason: `Cannot verify ${filePath}; Overleaf selected ${lastActivePath || 'unknown file'} but the editor document did not confirm the target file.`,
1057
+ lastActivePath,
1058
+ lastLength: String(lastText || '').length
1059
+ };
1060
+ }
1061
+
1062
+ function editorContentMatchesBase(filePath, content, baseFileLookup) {
1063
+ return compareEditorTextToBase(filePath, content, baseFileLookup).matches;
1064
+ }
1065
+
1066
+ function compareEditorTextToBase(filePath, content, baseFileLookup) {
1067
+ const normalizedPath = normalizeSafeProjectPath(filePath);
1068
+ const current = normalizeEditorTextForComparison(content);
1069
+ if (!baseFileLookup || !normalizedPath || !baseFileLookup.has(normalizedPath)) {
1070
+ return {
1071
+ known: false,
1072
+ hasContent: false,
1073
+ matches: false,
1074
+ baseLength: null,
1075
+ currentLength: current.length
1076
+ };
1077
+ }
1078
+ const base = normalizeEditorTextForComparison(baseFileLookup.get(normalizedPath));
1079
+ const matches = current === base;
1080
+ if (!matches && base.trim().length > 0) {
1081
+ console.warn('[codex-overleaf] editorContentMatchesBase MISMATCH', filePath,
1082
+ 'base.length:', base.length, 'current.length:', current.length,
1083
+ 'base[0..80]:', JSON.stringify(base.slice(0, 80)),
1084
+ 'current[0..80]:', JSON.stringify(current.slice(0, 80)));
1085
+ }
1086
+ return {
1087
+ known: true,
1088
+ hasContent: base.trim().length > 0,
1089
+ matches,
1090
+ baseLength: base.length,
1091
+ currentLength: current.length
1092
+ };
1093
+ }
1094
+
1095
+ function hasBaseFileContent(filePath, baseFileLookup) {
1096
+ if (!baseFileLookup || !filePath || !baseFileLookup.has(filePath)) {
1097
+ return false;
1098
+ }
1099
+ const content = baseFileLookup.get(filePath);
1100
+ return typeof content === 'string' && content.trim().length > 0;
1101
+ }
1102
+
1103
+ function normalizeEditorTextForComparison(value) {
1104
+ const normalized = String(value ?? '').replace(/\r\n/g, '\n');
1105
+ return normalized.trim().length === 0 ? '' : normalized;
1106
+ }
1107
+
1108
+ async function waitForFreshEditorTextForOperation(operation, baseFileLookup, waitMs = 1500) {
1109
+ const deadline = Date.now() + Math.max(0, Number(waitMs) || 0);
1110
+ while (Date.now() < deadline) {
1111
+ if (operation?.path && getActiveFilePath() !== operation.path) {
1112
+ await delay(60);
1113
+ continue;
1114
+ }
1115
+ const text = readActiveEditorText();
1116
+ const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
1117
+ operation,
1118
+ text,
1119
+ baseFileLookup
1120
+ ) || { ok: true };
1121
+ if (freshness.ok) {
1122
+ return {
1123
+ ok: true,
1124
+ text
1125
+ };
1126
+ }
1127
+ await delay(60);
1128
+ }
1129
+ return {
1130
+ ok: false
1131
+ };
1132
+ }
1133
+
1134
+ function applyTextPatches(text, patches) {
1135
+ const normalized = normalizeTextPatches(patches, text.length);
1136
+ if (!normalized.ok) {
1137
+ return normalized;
1138
+ }
1139
+
1140
+ let next = text;
1141
+ for (const patch of normalized.patches.slice().sort((left, right) => right.from - left.from)) {
1142
+ if (next.slice(patch.from, patch.to) !== patch.expected) {
1143
+ return {
1144
+ ok: false,
1145
+ code: 'stale_patch',
1146
+ reason: '这处内容已经和 Codex 读取时不同,所以没有写入。请重新运行,让 Codex 先读取你的最新 Overleaf 内容。'
1147
+ };
1148
+ }
1149
+ next = next.slice(0, patch.from) + patch.insert + next.slice(patch.to);
1150
+ }
1151
+ return {
1152
+ ok: true,
1153
+ text: next,
1154
+ patches: normalized.patches
1155
+ };
1156
+ }
1157
+
1158
+
1159
+ async function applyBinaryAssetOperation(operation, options = {}) {
1160
+ const freshness = await checkBinaryAssetFreshness(operation, options);
1161
+ if (!freshness.ok) {
1162
+ return freshness;
1163
+ }
1164
+
1165
+ const bytes = decodeBase64Bytes(operation.contentBase64);
1166
+ if (!bytes.ok) {
1167
+ return bytes;
1168
+ }
1169
+
1170
+ const manager = findFileTreeManager();
1171
+ const file = createAssetFile(operation.path, bytes.bytes);
1172
+ const methodNames = ['uploadFile', 'uploadAsset', 'createBinaryFile', 'createFile', 'addFile'];
1173
+ for (const methodName of methodNames) {
1174
+ const method = manager?.[methodName];
1175
+ if (typeof method !== 'function') {
1176
+ continue;
1177
+ }
1178
+ try {
1179
+ if (operation.type === 'overwrite-binary' && projectPathExists(operation.path)) {
1180
+ await method.call(manager, operation.path, file, { overwrite: true });
1181
+ } else {
1182
+ await method.call(manager, operation.path, file);
1183
+ }
1184
+ await delay(120);
1185
+ if (!projectPathExists(operation.path)) {
1186
+ return {
1187
+ ok: false,
1188
+ code: 'binary_asset_verification_failed',
1189
+ reason: `${operation.path} did not appear in the Overleaf file tree after binary asset upload.`
1190
+ };
1191
+ }
1192
+ return {
1193
+ ok: true,
1194
+ method: `fileTreeManager.${methodName}`,
1195
+ verified: true,
1196
+ binaryAsset: true
1197
+ };
1198
+ } catch (_error) {
1199
+ // Try the next known Overleaf file upload method.
1200
+ }
1201
+ }
1202
+
1203
+ return {
1204
+ ok: false,
1205
+ code: 'binary_asset_write_unsupported',
1206
+ reason: 'No supported Overleaf binary asset upload method was detected. The asset was not written.'
1207
+ };
1208
+ }
1209
+
1210
+ async function checkBinaryAssetFreshness(operation, options = {}) {
1211
+ const baseFileLookup = options.baseFileLookup;
1212
+ const baseBinaryFileLookup = options.baseBinaryFileLookup;
1213
+ if (!operation?.path) {
1214
+ return {
1215
+ ok: false,
1216
+ code: 'missing_operation_path',
1217
+ reason: 'Cannot safely write a binary asset without a target path.'
1218
+ };
1219
+ }
1220
+ if (operation.type === 'binary-create' && projectPathExists(operation.path)) {
1221
+ return {
1222
+ ok: false,
1223
+ code: 'path_created_since_snapshot',
1224
+ reason: `${operation.path} already exists in Overleaf. Codex did not overwrite it as a new asset.`
1225
+ };
1226
+ }
1227
+ if (operation.type === 'overwrite-binary' && baseFileLookup && !baseFileLookup.has(operation.path)) {
1228
+ return {
1229
+ ok: false,
1230
+ code: 'missing_base_file',
1231
+ reason: `${operation.path} was not present in the run baseline. Codex did not overwrite it.`
1232
+ };
1233
+ }
1234
+ if (operation.type === 'overwrite-binary' && !projectPathExists(operation.path)) {
1235
+ return {
1236
+ ok: false,
1237
+ code: 'missing_current_binary_asset',
1238
+ reason: `${operation.path} no longer exists in Overleaf. Codex did not overwrite it.`
1239
+ };
1240
+ }
1241
+ if (operation.type === 'overwrite-binary') {
1242
+ const baseBinaryFile = baseBinaryFileLookup?.get(operation.path);
1243
+ if (!baseBinaryFile) {
1244
+ return {
1245
+ ok: false,
1246
+ code: 'missing_binary_base_content',
1247
+ reason: `${operation.path} did not have binary baseline content. Codex did not overwrite it.`
1248
+ };
1249
+ }
1250
+ const currentBinaryFile = await getCurrentBinaryAssetFile(operation.path);
1251
+ if (!currentBinaryFile.ok) {
1252
+ return currentBinaryFile;
1253
+ }
1254
+ if (!binaryAssetMatchesBaseline(baseBinaryFile, currentBinaryFile.file)) {
1255
+ return {
1256
+ ok: false,
1257
+ code: 'stale_binary_asset',
1258
+ reason: `${operation.path} changed in Overleaf after the baseline. Codex did not overwrite it.`
1259
+ };
1260
+ }
1261
+ }
1262
+ return { ok: true };
1263
+ }
1264
+
1265
+ function buildBaseBinaryFileLookup(files) {
1266
+ if (!Array.isArray(files) || !files.length) {
1267
+ return null;
1268
+ }
1269
+ const lookup = new Map();
1270
+ for (const file of files) {
1271
+ if (!file?.path || typeof file.contentBase64 !== 'string') {
1272
+ continue;
1273
+ }
1274
+ const filePath = normalizeSafeProjectPath(file.path);
1275
+ if (!filePath) {
1276
+ continue;
1277
+ }
1278
+ lookup.set(filePath, {
1279
+ contentBase64: normalizeBase64(file.contentBase64),
1280
+ size: Number.isFinite(Number(file.size)) ? Number(file.size) : null
1281
+ });
1282
+ }
1283
+ return lookup.size ? lookup : null;
1284
+ }
1285
+
1286
+ async function getCurrentBinaryAssetFile(filePath) {
1287
+ try {
1288
+ const project = await projectSnapshotBridge.getProjectSnapshot({
1289
+ zipOnly: true,
1290
+ includeBinaryFiles: true,
1291
+ force: true,
1292
+ maxAgeMs: 0
1293
+ });
1294
+ const normalizedPath = normalizeSafeProjectPath(filePath);
1295
+ const file = (project?.files || []).find(item =>
1296
+ normalizeSafeProjectPath(item?.path) === normalizedPath
1297
+ );
1298
+ if (!file || typeof file.contentBase64 !== 'string') {
1299
+ return {
1300
+ ok: false,
1301
+ code: 'current_binary_asset_unavailable',
1302
+ reason: `${filePath} could not be verified from a fresh Overleaf ZIP snapshot. Codex did not overwrite it.`
1303
+ };
1304
+ }
1305
+ return { ok: true, file };
1306
+ } catch (error) {
1307
+ return {
1308
+ ok: false,
1309
+ code: 'current_binary_asset_unavailable',
1310
+ reason: `${filePath} could not be verified before overwrite: ${error.message}`
1311
+ };
1312
+ }
1313
+ }
1314
+
1315
+ function binaryAssetMatchesBaseline(baseFile, currentFile) {
1316
+ if (!baseFile || !currentFile) {
1317
+ return false;
1318
+ }
1319
+ const baseContent = normalizeBase64(baseFile.contentBase64);
1320
+ const currentContent = normalizeBase64(currentFile.contentBase64);
1321
+ if (!baseContent || baseContent !== currentContent) {
1322
+ return false;
1323
+ }
1324
+ const currentSize = Number.isFinite(Number(currentFile.size)) ? Number(currentFile.size) : null;
1325
+ return baseFile.size == null || currentSize == null || baseFile.size === currentSize;
1326
+ }
1327
+
1328
+ function normalizeBase64(value) {
1329
+ return String(value || '').replace(/\s+/g, '');
1330
+ }
1331
+
1332
+ function decodeBase64Bytes(contentBase64) {
1333
+ try {
1334
+ const binary = window.atob(String(contentBase64 || ''));
1335
+ const bytes = new Uint8Array(binary.length);
1336
+ for (let index = 0; index < binary.length; index += 1) {
1337
+ bytes[index] = binary.charCodeAt(index);
1338
+ }
1339
+ return { ok: true, bytes };
1340
+ } catch (_error) {
1341
+ return {
1342
+ ok: false,
1343
+ code: 'invalid_binary_asset_content',
1344
+ reason: 'Binary asset content was not valid base64.'
1345
+ };
1346
+ }
1347
+ }
1348
+
1349
+ function createAssetFile(filePath, bytes) {
1350
+ const name = String(filePath || '').split('/').filter(Boolean).pop() || 'asset';
1351
+ const type = inferAssetMimeType(filePath);
1352
+ if (typeof File === 'function') {
1353
+ return new File([bytes], name, { type });
1354
+ }
1355
+ return new Blob([bytes], { type });
1356
+ }
1357
+
1358
+ function inferAssetMimeType(filePath) {
1359
+ const normalized = String(filePath || '').toLowerCase();
1360
+ if (normalized.endsWith('.pdf')) {
1361
+ return 'application/pdf';
1362
+ }
1363
+ if (normalized.endsWith('.png')) {
1364
+ return 'image/png';
1365
+ }
1366
+ if (normalized.endsWith('.jpg') || normalized.endsWith('.jpeg')) {
1367
+ return 'image/jpeg';
1368
+ }
1369
+ if (normalized.endsWith('.svg')) {
1370
+ return 'image/svg+xml';
1371
+ }
1372
+ return 'application/octet-stream';
1373
+ }
1374
+
1375
+ async function applyFileTreeOperation(operation, options = {}) {
1376
+ const freshness = await checkFileTreeOperationFreshness(operation, options.baseFileLookup);
1377
+ if (!freshness.ok) {
1378
+ return freshness;
1379
+ }
1380
+
1381
+ const manager = findFileTreeManager();
1382
+ const methodNames = fileTreeMethodNames(operation.type);
1383
+
1384
+ for (const methodName of methodNames) {
1385
+ const method = manager?.[methodName];
1386
+ if (typeof method !== 'function') {
1387
+ continue;
1388
+ }
1389
+
1390
+ let mutationAttempted = false;
1391
+ try {
1392
+ mutationAttempted = true;
1393
+ if (operation.type === 'create') {
1394
+ await method.call(manager, operation.path, operation.content || '');
1395
+ } else if (operation.type === 'rename' || operation.type === 'move') {
1396
+ await method.call(manager, operation.path, operation.to);
1397
+ } else if (operation.type === 'delete') {
1398
+ await method.call(manager, operation.path);
1399
+ }
1400
+ const verified = await verifyFileTreeOperation(operation);
1401
+ if (!verified.ok) {
1402
+ return verified;
1403
+ }
1404
+ recordFileTreeOperationSuccess(operation, options.baseFileLookup);
1405
+ return {
1406
+ ok: true,
1407
+ method: `fileTreeManager.${methodName}`,
1408
+ verified: true
1409
+ };
1410
+ } catch (error) {
1411
+ if (mutationAttempted) {
1412
+ return {
1413
+ ok: false,
1414
+ code: 'file_tree_operation_unverified',
1415
+ reason: `Overleaf file-tree method ${methodName} did not complete cleanly after it was invoked. Codex stopped instead of trying another method that could stack a second partial change.`,
1416
+ method: `fileTreeManager.${methodName}`,
1417
+ error: error?.message || String(error || '')
1418
+ };
1419
+ }
1420
+ // Try the next known method name.
1421
+ }
1422
+ }
1423
+
1424
+ return {
1425
+ ok: false,
1426
+ reason: 'No supported Overleaf file-tree method was detected'
1427
+ };
1428
+ }
1429
+
1430
+ async function verifyFileTreeOperation(operation) {
1431
+ await delay(120);
1432
+ const sourcePath = operation.path;
1433
+ const targetPath = operation.to;
1434
+
1435
+ if (operation.type === 'create') {
1436
+ if (!projectPathExists(sourcePath)) {
1437
+ return fileTreeVerificationFailed(operation, `${sourcePath} 没有出现在 Overleaf 文件树中。`);
1438
+ }
1439
+ if (typeof operation.content === 'string' && window.CodexOverleafProjectFiles.isTextProjectPath(sourcePath)) {
1440
+ const opened = await openFileByPath(sourcePath);
1441
+ if (!opened.ok) {
1442
+ return fileTreeVerificationFailed(operation, `${sourcePath} 创建后无法打开验证内容。`);
1443
+ }
1444
+ const verified = await verifyActiveEditorText(operation.content, sourcePath, 600);
1445
+ if (!verified.ok) {
1446
+ return fileTreeVerificationFailed(operation, `${sourcePath} 创建后内容没有和 Codex 预期一致。`);
1447
+ }
1448
+ }
1449
+ return { ok: true };
1450
+ }
1451
+
1452
+ if (operation.type === 'delete') {
1453
+ return projectPathExists(sourcePath)
1454
+ ? fileTreeVerificationFailed(operation, `${sourcePath} 仍然存在。`)
1455
+ : { ok: true };
1456
+ }
1457
+
1458
+ if (operation.type === 'rename' || operation.type === 'move') {
1459
+ if (!targetPath || !projectPathExists(targetPath)) {
1460
+ return fileTreeVerificationFailed(operation, `${targetPath || '目标路径'} 没有出现在 Overleaf 文件树中。`);
1461
+ }
1462
+ if (projectPathExists(sourcePath)) {
1463
+ return fileTreeVerificationFailed(operation, `${sourcePath} 仍然存在。`);
1464
+ }
1465
+ return { ok: true };
1466
+ }
1467
+
1468
+ return { ok: true };
1469
+ }
1470
+
1471
+ function fileTreeVerificationFailed(operation, reason) {
1472
+ return {
1473
+ ok: false,
1474
+ code: 'file_tree_verification_failed',
1475
+ reason: `Overleaf 文件树操作没有被确认:${reason} Codex 已停止把这次操作标记为成功。`,
1476
+ operationType: operation?.type || '',
1477
+ path: operation?.path || '',
1478
+ to: operation?.to || ''
1479
+ };
1480
+ }
1481
+
1482
+ async function checkFileTreeOperationFreshness(operation, baseFileLookup) {
1483
+ if (!baseFileLookup) {
1484
+ return { ok: true };
1485
+ }
1486
+
1487
+ if (!operation.path) {
1488
+ return {
1489
+ ok: false,
1490
+ code: 'missing_operation_path',
1491
+ reason: `Cannot safely ${operation.type || 'modify'} a file without a target path.`
1492
+ };
1493
+ }
1494
+
1495
+ if (operation.type === 'create') {
1496
+ if (baseFileLookup.has(operation.path)) {
1497
+ return {
1498
+ ok: false,
1499
+ code: 'path_exists_in_snapshot',
1500
+ reason: `${operation.path} 在任务开始前已经存在。Codex 没有覆盖它;请改用修改文件或换一个文件名。`
1501
+ };
1502
+ }
1503
+ if (projectPathExists(operation.path)) {
1504
+ return {
1505
+ ok: false,
1506
+ code: 'path_created_since_snapshot',
1507
+ reason: `${operation.path} 在任务执行期间被你或协作者新建了,Codex 没有覆盖它。请查看差异后重试。`
1508
+ };
1509
+ }
1510
+ return { ok: true };
1511
+ }
1512
+
1513
+ if (!baseFileLookup.has(operation.path)) {
1514
+ return {
1515
+ ok: false,
1516
+ code: 'missing_base_file',
1517
+ reason: `${operation.path} 在任务开始时没有被 Codex 读到。Codex 没有覆盖它;请刷新项目内容后重试。`
1518
+ };
1519
+ }
1520
+
1521
+ const current = await readCurrentTextFileForFreshness(operation.path);
1522
+ if (!current.ok) {
1523
+ return current;
1524
+ }
1525
+
1526
+ const freshness = window.CodexOverleafStaleGuard?.checkOperationFreshness(
1527
+ { type: 'edit', path: operation.path },
1528
+ current.text,
1529
+ baseFileLookup
1530
+ ) || { ok: true };
1531
+ if (!freshness.ok) {
1532
+ return freshness;
1533
+ }
1534
+
1535
+ if ((operation.type === 'rename' || operation.type === 'move') && operation.to) {
1536
+ if (baseFileLookup.has(operation.to) || projectPathExists(operation.to)) {
1537
+ return {
1538
+ ok: false,
1539
+ code: 'destination_exists',
1540
+ reason: `Cannot safely ${operation.type} ${operation.path} to ${operation.to} because the destination already exists.`
1541
+ };
1542
+ }
1543
+ }
1544
+
1545
+ return { ok: true };
1546
+ }
1547
+
1548
+ async function readCurrentTextFileForFreshness(filePath) {
1549
+ const normalizedPath = normalizeSafeProjectPath(filePath);
1550
+ if (!normalizedPath) {
1551
+ return invalidProjectPathResult('freshness path');
1552
+ }
1553
+ const currentPath = getActiveFilePath();
1554
+ const previousEditorIdentity = getActiveEditorIdentity();
1555
+ const opened = await openFileByPath(normalizedPath, { force: true });
1556
+ if (!opened.ok) {
1557
+ return {
1558
+ ok: false,
1559
+ code: 'cannot_verify_current_file',
1560
+ reason: `Cannot safely verify ${normalizedPath}; ${opened.reason || 'open failed'}. Re-run Codex on a fresh snapshot.`
1561
+ };
1562
+ }
1563
+ const ready = await waitForEditorDocumentForPath(normalizedPath, previousEditorIdentity, 9000, {
1564
+ initialActivePath: currentPath,
1565
+ forceSettledOpen: true
1566
+ });
1567
+ if (!ready.ok) {
1568
+ return {
1569
+ ...ready,
1570
+ code: 'cannot_verify_current_file'
1571
+ };
1572
+ }
1573
+
1574
+ return {
1575
+ ok: true,
1576
+ text: ready.text
1577
+ };
1578
+ }
1579
+
1580
+ function recordFileTreeOperationSuccess(operation, baseFileLookup) {
1581
+ if (!baseFileLookup) {
1582
+ return;
1583
+ }
1584
+
1585
+ if (operation.type === 'create') {
1586
+ window.CodexOverleafStaleGuard?.updateExpectedFileContent(
1587
+ baseFileLookup,
1588
+ operation.path,
1589
+ operation.content || ''
1590
+ );
1591
+ } else if (operation.type === 'delete') {
1592
+ window.CodexOverleafStaleGuard?.removeExpectedFile(baseFileLookup, operation.path);
1593
+ } else if (operation.type === 'rename' || operation.type === 'move') {
1594
+ window.CodexOverleafStaleGuard?.moveExpectedFile(baseFileLookup, operation.path, operation.to);
1595
+ }
1596
+ }
1597
+
1598
+ function fileTreeMethodNames(type) {
1599
+ return {
1600
+ create: ['createDoc', 'createFile', 'addDoc', 'addFile'],
1601
+ rename: ['renameEntity', 'renameFile', 'renameDoc'],
1602
+ move: ['moveEntity', 'moveFile', 'moveDoc'],
1603
+ delete: ['deleteEntity', 'deleteFile', 'deleteDoc', 'removeEntity']
1604
+ }[type] || [];
1605
+ }
1606
+
1607
+ function normalizeOperationPaths(operation) {
1608
+ if (!operation || typeof operation !== 'object') {
1609
+ return operation;
1610
+ }
1611
+ const normalized = {
1612
+ ...operation,
1613
+ path: typeof operation.path === 'string'
1614
+ ? normalizeSafeProjectPath(operation.path)
1615
+ : operation.path,
1616
+ to: typeof operation.to === 'string'
1617
+ ? normalizeSafeProjectPath(operation.to)
1618
+ : operation.to
1619
+ };
1620
+ if (typeof operation.path === 'string' && !normalized.path) {
1621
+ normalized.invalidProjectPath = true;
1622
+ }
1623
+ if (typeof operation.to === 'string' && !normalized.to) {
1624
+ normalized.invalidProjectDestinationPath = true;
1625
+ }
1626
+ return normalized;
1627
+ }
1628
+
1629
+ function validateOperationProjectPaths(operation) {
1630
+ if (!operation || typeof operation !== 'object') {
1631
+ return { ok: true };
1632
+ }
1633
+ if (operation.invalidProjectPath || (requiresOperationPath(operation) && !operation.path)) {
1634
+ return invalidProjectPathResult('operation path');
1635
+ }
1636
+ if (operation.invalidProjectDestinationPath || (requiresOperationDestinationPath(operation) && !operation.to)) {
1637
+ return invalidProjectPathResult('operation destination path');
1638
+ }
1639
+ return { ok: true };
1640
+ }
1641
+
1642
+ function requiresOperationPath(operation) {
1643
+ return ['edit', 'create', 'delete', 'rename', 'move', 'binary-create', 'overwrite-binary'].includes(operation.type);
1644
+ }
1645
+
1646
+ function requiresOperationDestinationPath(operation) {
1647
+ return operation.type === 'rename' || operation.type === 'move';
1648
+ }
1649
+
1650
+ function normalizeBaseFilesForSafety(files) {
1651
+ if (!Array.isArray(files)) {
1652
+ return files;
1653
+ }
1654
+ return files
1655
+ .map(file => {
1656
+ if (!file || typeof file.path !== 'string') {
1657
+ return null;
1658
+ }
1659
+ const filePath = normalizeSafeProjectPath(file.path);
1660
+ return filePath ? { ...file, path: filePath } : null;
1661
+ })
1662
+ .filter(Boolean);
1663
+ }
1664
+
1665
+ function collectOperationPaths(operations = []) {
1666
+ const paths = new Set();
1667
+ for (const rawOperation of operations || []) {
1668
+ const operation = normalizeOperationPaths(rawOperation);
1669
+ for (const value of [operation?.path, operation?.to]) {
1670
+ if (typeof value === 'string' && value) {
1671
+ paths.add(value);
1672
+ }
1673
+ }
1674
+ }
1675
+ return Array.from(paths);
1676
+ }
1677
+
1678
+
1679
+
1680
+
1681
+
1682
+
1683
+ function normalizeTrackedChangeRefs(refs = []) {
1684
+ const seen = new Set();
1685
+ const normalized = [];
1686
+ for (const ref of refs || []) {
1687
+ if (!ref || typeof ref !== 'object') {
1688
+ continue;
1689
+ }
1690
+ const key = typeof ref.key === 'string' ? ref.key : '';
1691
+ if (!key || seen.has(key)) {
1692
+ continue;
1693
+ }
1694
+ seen.add(key);
1695
+ const hasPath = typeof ref.path === 'string';
1696
+ const hasExplicitPath = hasPath && ref.path.trim() !== '';
1697
+ const path = hasPath ? normalizeSafeProjectPath(ref.path) : '';
1698
+ normalized.push({
1699
+ key,
1700
+ id: typeof ref.id === 'string' ? ref.id : '',
1701
+ path,
1702
+ invalidProjectPath: hasExplicitPath && !path,
1703
+ label: compact(String(ref.label || ''), 180)
1704
+ });
1705
+ }
1706
+ return normalized;
1707
+ }
1708
+
1709
+
1710
+ function diffTrackedChangeRefs(before = [], after = []) {
1711
+ const beforeKeys = new Set((before || []).map(ref => ref.key).filter(Boolean));
1712
+ const seen = new Set();
1713
+ const added = [];
1714
+ for (const ref of after || []) {
1715
+ if (!ref?.key || beforeKeys.has(ref.key) || seen.has(ref.key)) {
1716
+ continue;
1717
+ }
1718
+ seen.add(ref.key);
1719
+ added.push(ref);
1720
+ }
1721
+ return added;
1722
+ }
1723
+
1724
+ function mergeTrackedChangeRefs(refs = []) {
1725
+ return normalizeTrackedChangeRefs(refs);
1726
+ }
1727
+
1728
+
1729
+
1730
+
1731
+
1732
+
1733
+
1734
+ function isEditorUndoControl(node) {
1735
+ if (!node || isInsideCodexPanel(node) || node.disabled) {
1736
+ return false;
1737
+ }
1738
+ if (/^(true|disabled)$/i.test(node.getAttribute?.('aria-disabled') || '')) {
1739
+ return false;
1740
+ }
1741
+ const signal = normalizeReviewingSignalText(readNodeSignalText(node));
1742
+ if (/\b(?:redo|重做)\b/i.test(signal)) {
1743
+ return false;
1744
+ }
1745
+ return /\bundo\b|撤销/i.test(signal);
1746
+ }
1747
+
1748
+
1749
+ async function applyOperationsForBridge(operationsOrOptions, options = {}) {
1750
+ if (Array.isArray(operationsOrOptions)) {
1751
+ // Welcome-panel + write-guard: defense-in-depth
1752
+ // also runs on the array-shaped entry. T2 only guarded the
1753
+ // payload-object branch; if any future caller goes straight to the
1754
+ // array entry without `options.runProjectId`, the router would have
1755
+ // silently dispatched. Mirror the same `editor_project_id_unavailable`
1756
+ // failure shape the payload branch and the page-side guard already
1757
+ // emit.
1758
+ const writeGuardBlock = checkWritebackRunProjectId(options);
1759
+ if (writeGuardBlock) return writeGuardBlock;
1760
+ return applyOperations(operationsOrOptions, options);
1761
+ }
1762
+ const payload = operationsOrOptions || {};
1763
+ // Welcome-panel + write-guard: defense-in-depth.
1764
+ // The pageBridge wrapper already runs the runProjectId guard, but a
1765
+ // future caller could reach the router directly. A missing or empty
1766
+ // `runProjectId` on the payload-shaped call blocks the write with the
1767
+ // same `editor_project_id_unavailable` shape the page-side guard uses.
1768
+ const writeGuardBlock = checkWritebackRunProjectId(payload);
1769
+ if (writeGuardBlock) return writeGuardBlock;
1770
+ return applyOperations(payload.operations || [], payload);
1771
+ }
1772
+
1773
+ function checkWritebackRunProjectId(params) {
1774
+ const runProjectId = params && typeof params.runProjectId === 'string' ? params.runProjectId : '';
1775
+ if (runProjectId) return null;
1776
+ // operation: {} (not null) — batch-level guard fired before any per-op
1777
+ // dispatch; matches the abortDispatchResult shape in pageBridge.js and
1778
+ // avoids crashing downstream audit / transcript code that reads
1779
+ // operation.path without a null guard.
1780
+ return {
1781
+ ok: false,
1782
+ applied: [],
1783
+ skipped: [{
1784
+ operation: {},
1785
+ result: {
1786
+ ok: false,
1787
+ code: 'editor_project_id_unavailable',
1788
+ reason: 'Writeback request did not carry runProjectId.',
1789
+ failure: {
1790
+ code: 'editor_project_id_unavailable',
1791
+ stage: 'write',
1792
+ severity: 'blocked',
1793
+ userMessage: 'Codex could not confirm which Overleaf project the editor is showing, so it did not write.',
1794
+ nextAction: 'Refresh Overleaf and retry; if it persists, reload the extension.',
1795
+ retryable: true,
1796
+ terminalState: 'blocked',
1797
+ changedDocument: false,
1798
+ evidence: { runProjectId: null }
1799
+ }
1800
+ }
1801
+ }]
1802
+ };
1803
+ }
1804
+
1805
+ async function verifySaveState(paths = [], timeoutMs = 5000) {
1806
+ const result = deps.waitForSaveState
1807
+ ? await deps.waitForSaveState({ deadlineMs: timeoutMs })
1808
+ : { ok: false, state: 'unavailable' };
1809
+ const verified = result.ok === true && result.state === 'verified_saved';
1810
+ return {
1811
+ verified,
1812
+ unverifiedPaths: verified ? [] : (paths || [])
1813
+ };
1814
+ }
1815
+
1816
+ function buildNoTraceUndoBlockedResult(operations, disabled) {
1817
+ return {
1818
+ ok: false,
1819
+ applied: [],
1820
+ skipped: (operations || []).map(rawOperation => ({
1821
+ operation: normalizeOperationPaths(rawOperation),
1822
+ result: {
1823
+ ok: false,
1824
+ code: disabled?.code || 'reviewing_disable_failed',
1825
+ reason: disabled?.reason || '无法确认 Overleaf Reviewing/Track Changes 已关闭;为避免撤销也留下新的留痕,Codex 没有执行撤销。'
1826
+ }
1827
+ })),
1828
+ reviewingPolicy: {
1829
+ policy: 'no-trace-undo',
1830
+ disabled: false,
1831
+ restored: false,
1832
+ disable: summarizeReviewingToggleResult(disabled)
1833
+ }
1834
+ };
1835
+ }
1836
+
1837
+ function buildReviewingRequiredBlockedResult(operations, reviewing) {
1838
+ return {
1839
+ ok: false,
1840
+ applied: [],
1841
+ skipped: (operations || []).map(rawOperation => ({
1842
+ operation: normalizeOperationPaths(rawOperation),
1843
+ result: {
1844
+ ok: false,
1845
+ code: reviewing?.code || 'reviewing_not_enabled',
1846
+ reason: reviewing?.reason || 'Overleaf Reviewing/Track Changes was not confirmed before writing. Codex did not change this file.',
1847
+ reviewing: reviewing?.reviewing || null
1848
+ }
1849
+ })),
1850
+ reviewing
1851
+ };
1852
+ }
1853
+
1854
+ function buildEditingRequiredBlockedResult(operations, editing) {
1855
+ return {
1856
+ ok: false,
1857
+ applied: [],
1858
+ skipped: (operations || []).map(rawOperation => ({
1859
+ operation: normalizeOperationPaths(rawOperation),
1860
+ result: {
1861
+ ok: false,
1862
+ code: editing?.code || 'editing_not_confirmed',
1863
+ reason: editing?.reason || 'Overleaf Editing mode was not confirmed before writing. Codex did not change this file.',
1864
+ reviewing: editing?.reviewing || null
1865
+ }
1866
+ })),
1867
+ reviewing: editing
1868
+ };
1869
+ }
1870
+
1871
+ // Tracked-changes lifecycle — carved to trackedChangesLifecycle.js in
1872
+ // v1.8.0 (structural-debt phase 7, #117). Loaded just before this file
1873
+ // in the manifest's page-script list; the Node test path requires it.
1874
+ const trackedChangesLifecycleModule = (typeof window !== 'undefined' && window.CodexOverleafTrackedChangesLifecycle)
1875
+ || (typeof module === 'object' && module.exports ? require('./trackedChangesLifecycle.js') : null)
1876
+ || (typeof globalThis !== 'undefined' ? globalThis.CodexOverleafTrackedChangesLifecycle : null);
1877
+ const {
1878
+ acceptTrackedChanges,
1879
+ rejectTrackedChanges,
1880
+ collectTrackedChangeRefsForPaths,
1881
+ waitForTrackedChangeDiff
1882
+ } = trackedChangesLifecycleModule.create({
1883
+ window,
1884
+ checkWritebackRunProjectId,
1885
+ buildNoTraceUndoBlockedResult,
1886
+ buildReviewingRequiredBlockedResult,
1887
+ buildEditingRequiredBlockedResult,
1888
+ compileBridge,
1889
+ applyOperationsCore,
1890
+ clickNode,
1891
+ compact,
1892
+ invalidProjectPathResult,
1893
+ isEditingConfirmedForNoTraceUndo,
1894
+ isReviewingConfirmedForWrite,
1895
+ uniqueNodes,
1896
+ normalizeTrackedChangeRefs,
1897
+ applyOperationsWithNoTraceUndo,
1898
+ applyTextPatches,
1899
+ buildPageFailure,
1900
+ delay,
1901
+ getActiveFilePath,
1902
+ getReviewingState,
1903
+ isEditorUndoControl,
1904
+ isInsideCodexPanel,
1905
+ normalizeOperationPaths,
1906
+ normalizeReviewingSignalText,
1907
+ normalizeSafeProjectPath,
1908
+ openFileByPath,
1909
+ readActiveEditorText,
1910
+ readNodeSignalText,
1911
+ setReviewingEnabled,
1912
+ summarizeReviewingToggleResult,
1913
+ verifyActiveEditorText,
1914
+ collectElements,
1915
+ });
1916
+
1917
+ return {
1918
+ applyOperations: applyOperationsForBridge,
1919
+ rejectTrackedChanges,
1920
+ acceptTrackedChanges,
1921
+ verifySaveState
1922
+ };
1923
+ }
1924
+
1925
+ function fallbackNormalizeSafeProjectPath(value) {
1926
+ const text = String(value || '').replace(/\\/g, '/').replace(/\/+/g, '/').replace(/^\/+|\/+$/g, '').trim();
1927
+ if (!text || text.split('/').some(part => !part || part === '.' || part === '..')) {
1928
+ return '';
1929
+ }
1930
+ return text;
1931
+ }
1932
+
1933
+ return { create };
1934
+ });