codex-overleaf-link 2.5.1 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +148 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -12,12 +12,13 @@
12
12
  require('./subagentActivityModel'),
13
13
  require('./writebackIntent'),
14
14
  require('./directoryCleanupProof'),
15
- require('./undoOperations')
15
+ require('./undoOperations'),
16
+ require('./contextUsage')
16
17
  );
17
18
  } else {
18
19
  root.CodexOverleafModuleRegistry.define(
19
20
  'SessionState',
20
- ['I18n', 'SettlementFacts', 'RunInputQueue', 'SelectionContext', 'LineReferences', 'PathRedaction', 'SessionTitle', 'RunActivityModel', 'SubagentActivityModel', 'WritebackIntent', 'DirectoryCleanupProof', 'UndoOperations'],
21
+ ['I18n', 'SettlementFacts', 'RunInputQueue', 'SelectionContext', 'LineReferences', 'PathRedaction', 'SessionTitle', 'RunActivityModel', 'SubagentActivityModel', 'WritebackIntent', 'DirectoryCleanupProof', 'UndoOperations', 'ContextUsage'],
21
22
  factory
22
23
  );
23
24
  }
@@ -33,7 +34,8 @@
33
34
  SubagentActivityModel,
34
35
  WritebackIntent,
35
36
  DirectoryCleanupProof,
36
- UndoOperations
37
+ UndoOperations,
38
+ ContextUsage
37
39
  ) {
38
40
  'use strict';
39
41
 
@@ -250,6 +252,7 @@
250
252
  selectionContext: SelectionContext.normalize(session.selectionContext),
251
253
  projectReferenceFiles: normalizeProjectReferenceFiles(session.projectReferenceFiles),
252
254
  codexThreadId: typeof session.codexThreadId === 'string' ? session.codexThreadId : '',
255
+ contextUsage: ContextUsage.normalize(session.contextUsage),
253
256
  forkedFromSessionId: typeof session.forkedFromSessionId === 'string' ? session.forkedFromSessionId : '',
254
257
  forkedFromRunId: typeof session.forkedFromRunId === 'string' ? session.forkedFromRunId : '',
255
258
  pendingInputs: normalizePendingInputs(
@@ -330,6 +333,7 @@
330
333
  focusFiles: normalizeFocusFiles(active.focusFiles),
331
334
  projectReferenceFiles: normalizeProjectReferenceFiles(active.projectReferenceFiles),
332
335
  codexThreadId: active.codexThreadId || '',
336
+ contextUsage: ContextUsage.normalize(active.contextUsage),
333
337
  providerId: state.providerId || 'builtin'
334
338
  };
335
339
  state.runs = Array.isArray(active.runs) ? active.runs : [];
@@ -374,6 +378,7 @@
374
378
  selectionContext: SelectionContext.normalize(overrides.selectionContext),
375
379
  projectReferenceFiles: normalizeProjectReferenceFiles(overrides.projectReferenceFiles),
376
380
  codexThreadId: typeof overrides.codexThreadId === 'string' ? overrides.codexThreadId : '',
381
+ contextUsage: ContextUsage.normalize(overrides.contextUsage),
377
382
  forkedFromSessionId: typeof overrides.forkedFromSessionId === 'string' ? overrides.forkedFromSessionId : '',
378
383
  forkedFromRunId: typeof overrides.forkedFromRunId === 'string' ? overrides.forkedFromRunId : '',
379
384
  pendingInputs: normalizePendingInputs(overrides.pendingInputs)
@@ -439,6 +444,34 @@
439
444
  });
440
445
  }
441
446
 
447
+ // Composer input changes only the draft on an already normalized state.
448
+ // Keep live run/event objects intact so typing cannot detach their writers.
449
+ function updateActiveSessionDraft(state, task) {
450
+ const active = getActiveSession(state);
451
+ if (!active) {
452
+ return mirrorActiveSession({ ...state });
453
+ }
454
+ const draft = sanitizeAssistantVisibleText(task);
455
+ if (draft === active.task && draft === state.task) {
456
+ return state;
457
+ }
458
+ const rawTitle = typeof active.title === 'string' ? active.title.trim() : '';
459
+ const derivedTitle = deriveSessionTitle(active.runs, task);
460
+ const titleSource = normalizeTitleSource(active.titleSource, rawTitle, derivedTitle);
461
+ const next = {
462
+ ...active,
463
+ task: draft,
464
+ updatedAt: new Date().toISOString(),
465
+ title: normalizeSessionTitle(rawTitle, derivedTitle, titleSource),
466
+ titleSource
467
+ };
468
+ return {
469
+ ...state,
470
+ sessions: state.sessions.map(session => session === active ? next : session),
471
+ task: next.task
472
+ };
473
+ }
474
+
442
475
  function setActiveSession(state, sessionId) {
443
476
  if (!(state.sessions || []).some(session => session.id === sessionId)) {
444
477
  return mirrorActiveSession(state);
@@ -665,7 +698,17 @@
665
698
  ...(value.directoryCleanup === true ? { directoryCleanup: true, ...(value.coreRetained === true ? { coreRetained: true } : {}) } : {}) };
666
699
  }
667
700
 
701
+ // Normalization makes UI copies of the same record. This identity is page-local:
702
+ // it never travels through JSON/storage and is never shared by a replacement run.
703
+ const runIdentities = new WeakMap();
704
+ function sameRunIdentity(left, right) {
705
+ return Boolean(left && right && (left === right
706
+ || (runIdentities.has(left) && runIdentities.get(left) === runIdentities.get(right))));
707
+ }
708
+
668
709
  function normalizeRun(run, options = {}) {
710
+ const sourceRun = run;
711
+
669
712
  if (options.restoreRunningRuns === true || options.recoverSettledRuns === true) run = recoverSettledRunningRun(run);
670
713
  run = recoverRecordedRunFailure(run, options.locale);
671
714
  const shouldStopRestoredRun = options.restoreRunningRuns === true && run.status === 'running';
@@ -681,6 +724,12 @@
681
724
  status: 'failed',
682
725
  kind: 'report',
683
726
  detail: i18n.t(locale, 'restoredRunStoppedDetail'),
727
+ detailStructured: {
728
+ conclusion: i18n.t(locale, 'restoredRunStoppedDetail'),
729
+ conclusionSource: { origin: 'product', key: 'reload_interrupted' },
730
+ // Ask forbids writeback; an interrupted Auto run has no zero-write proof.
731
+ meta: run.mode === 'ask' ? [{ key: 'writeResult', value: 'wrote 0 items, skipped 0 items' }] : []
732
+ },
684
733
  failure: { code: 'native_request_failed', source: 'panel_reload' },
685
734
  timestamp: new Date().toISOString()
686
735
  });
@@ -715,7 +764,9 @@
715
764
  undoStatus: sanitizeAssistantVisibleText(run.undoStatus),
716
765
  ...(run.undoOwnerProtocol === 1 ? { undoOwnerProtocol: 1 } : {}),
717
766
  nativeRequestId: sanitizeAssistantVisibleText(run.nativeRequestId),
767
+ ...(run.operation === 'compact' ? { operation: 'compact' } : {}),
718
768
  codexThreadId: sanitizeAssistantVisibleText(run.codexThreadId), codexTurnId: sanitizeAssistantVisibleText(run.codexTurnId),
769
+ contextUsage: run.forkSnapshot === true ? null : ContextUsage.normalize(run.contextUsage),
719
770
  forkSnapshot: run.forkSnapshot === true,
720
771
  forkSourceRunId: sanitizeAssistantVisibleText(run.forkSourceRunId),
721
772
  nativeEventSeq: Number.isFinite(Number(run.nativeEventSeq)) ? Number(run.nativeEventSeq) : 0,
@@ -744,6 +795,9 @@
744
795
  applyTrackedChangeStatus(normalized, run.trackedChangeStatus);
745
796
  Object.assign(normalized, pickWritebackRecovery(run));
746
797
 
798
+ const identity = runIdentities.get(sourceRun) || {};
799
+ runIdentities.set(sourceRun, identity);
800
+ runIdentities.set(normalized, identity);
747
801
  return normalized;
748
802
  }
749
803
 
@@ -1231,6 +1285,7 @@
1231
1285
  selectionContext: SelectionContext.normalize(session.selectionContext),
1232
1286
  projectReferenceFiles: normalizeProjectReferenceFiles(session.projectReferenceFiles),
1233
1287
  codexThreadId: typeof session.codexThreadId === 'string' ? session.codexThreadId : '',
1288
+ contextUsage: ContextUsage.normalize(session.contextUsage),
1234
1289
  forkedFromSessionId: normalizeTextField(session.forkedFromSessionId, 160),
1235
1290
  forkedFromRunId: normalizeTextField(session.forkedFromRunId, 160),
1236
1291
  pendingInputs: normalizePendingInputs(session.pendingInputs)
@@ -1319,7 +1374,7 @@
1319
1374
  events: compactRunEvents(run.events, limits),
1320
1375
  ...(run.subagents?.length ? { subagents: SubagentActivityModel.normalize(run.subagents, sanitizeAssistantVisibleText) } : {}),
1321
1376
  attachments: compactRunAttachmentsForStorage(run.attachments, limits),
1322
- appliedOperations: [],
1377
+ appliedOperations: UndoOperations.compactUnavailableUndoOperations(run),
1323
1378
  undoOperations: undoPayload.undoOperations,
1324
1379
  undoBaseFiles: undoPayload.undoBaseFiles,
1325
1380
  undoTrackedChanges: undoPayload.undoTrackedChanges,
@@ -1332,7 +1387,9 @@
1332
1387
  undoStatus: summarizeTextForStorage(run.undoStatus, 'undo status'),
1333
1388
  ...(run.undoOwnerProtocol === 1 ? { undoOwnerProtocol: 1 } : {}),
1334
1389
  nativeRequestId: normalizeTextField(run.nativeRequestId, 160),
1390
+ ...(run.operation === 'compact' ? { operation: 'compact' } : {}),
1335
1391
  codexThreadId: normalizeTextField(run.codexThreadId, 160), codexTurnId: normalizeTextField(run.codexTurnId, 160),
1392
+ contextUsage: run.forkSnapshot === true ? null : ContextUsage.normalize(run.contextUsage),
1336
1393
  forkSnapshot: run.forkSnapshot === true,
1337
1394
  forkSourceRunId: normalizeTextField(run.forkSourceRunId, 160),
1338
1395
  nativeEventSeq: Number.isFinite(Number(run.nativeEventSeq)) ? Number(run.nativeEventSeq) : 0,
@@ -1744,6 +1801,7 @@
1744
1801
  }
1745
1802
 
1746
1803
  return {
1804
+ sameRunIdentity,
1747
1805
  normalizeRecoveryRetention,
1748
1806
  pruneRunRecoveryScope,
1749
1807
  pickWritebackRecovery,
@@ -1759,6 +1817,7 @@
1759
1817
  selectVisibleSessionsForList,
1760
1818
  setActiveSession,
1761
1819
  updateActiveSession,
1820
+ updateActiveSessionDraft,
1762
1821
  normalizeRuns,
1763
1822
  normalizeRunStatus,
1764
1823
  recoverRecordedRunFailure,
@@ -25,6 +25,19 @@
25
25
  }
26
26
 
27
27
  function fromPrompt(value, { sanitize = text => text, maxChars = 24 } = {}) {
28
+ let title = projectPrompt(value, { sanitize, maxChars });
29
+ // Display titles pass through normalization again on unrelated callbacks.
30
+ // Finish removing nested formatting/prefixes now, before acknowledging a title.
31
+ // The bound guards against an input that never settles.
32
+ for (let pass = 0; pass < 8; pass += 1) {
33
+ const next = projectPrompt(title, { sanitize, maxChars });
34
+ if (next === title) return title;
35
+ title = next;
36
+ }
37
+ return title;
38
+ }
39
+
40
+ function projectPrompt(value, { sanitize, maxChars }) {
28
41
  if (typeof value !== 'string' || !value.trim()) return '';
29
42
  const source = String(sanitize(value.slice(0, INPUT_LIMIT)) || '');
30
43
  let fence = null;
@@ -75,7 +88,7 @@
75
88
  .replace(/\[(?:REDACTED_SECRET|local path(?::\d+)?)\]/gi, ' ')
76
89
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
77
90
  .replace(/\s+/g, ' ').trim()
78
- .replace(/^(?:\u8bf7(?:\u4f60)?\u5e2e\u6211|\u8bf7\u4f60|\u5e2e\u6211|\u9ebb\u70e6\u4f60(?:\u5e2e\u6211)?|\u9ebb\u70e6\u5e2e\u6211|please\s+)\s*/i, '');
91
+ .replace(/^(?:(?:\u8bf7(?:\u4f60)?\u5e2e\u6211|\u8bf7\u4f60|\u5e2e\u6211|\u9ebb\u70e6\u4f60(?:\u5e2e\u6211)?|\u9ebb\u70e6\u5e2e\u6211|please\s+)\s*)+/i, '');
79
92
  }
80
93
 
81
94
  function isScaffolding(value) {
@@ -160,7 +160,9 @@
160
160
  Object.defineProperty(record, BASE, { value: base, enumerable: true });
161
161
  return record;
162
162
  }
163
- async function list(projectId = '', adopt = true) {
163
+ // List reads keep a per-record merge base without changing the visible view's
164
+ // baseline. Hydration explicitly adopts; passive refresh adopts after apply.
165
+ async function list(projectId = '', adopt = false) {
164
166
  const value = capture(projectId);
165
167
  await migrate(value);
166
168
  const result = []; let after = '';
@@ -185,29 +187,94 @@
185
187
  return Boolean(base && (session.updatedAt !== base.updatedAt || session.task !== base.task));
186
188
  }
187
189
  function wasStored(id) { return baselines.has(cacheKey(capture(), id)); }
190
+ function acknowledgeReviewBaseline(base, record, receipt, runIds) {
191
+ if (!base || !receipt) return base;
192
+ // Only this caller's acknowledged review fields have been submitted.
193
+ // The envelope base still describes its own view, never the latest read.
194
+ return { ...base, runs: (base.runs || []).map(run => {
195
+ const proposed = record.runs?.find(item => item.id === run.id);
196
+ const saved = receipt.runs?.find(item => item.id === run.id);
197
+ return runIds.includes(run.id) && proposed && saved
198
+ && legacy.reviewStateSnapshot(proposed) === legacy.reviewStateSnapshot(saved)
199
+ ? legacy.copyRunReviewState(run, proposed) : run;
200
+ }) };
201
+ }
188
202
  async function put(records, options = {}) {
189
203
  if (!records.length) return [];
190
204
  const value = capture(records[0].projectId, records[0].accountScopeId);
191
205
  if (records.some(record => record.projectId !== value.projectId || record.accountScopeId !== value.accountScopeId))
192
206
  throw failure('shared_store_scope_mismatch', 'A session batch must belong to one account and project.');
193
207
  return withWrite(value, async () => {
194
- const entries = records.map(record => ({
195
- record: clean(record),
196
- base: record[BASE] || baselines.get(cacheKey(value, record.id)) || null
197
- }));
208
+ const entries = records.map(record => {
209
+ let base = record[BASE] || baselines.get(cacheKey(value, record.id)) || null;
210
+ const receipt = options.reviewReceipts?.find(saved => saved.id === record.id
211
+ && saved.projectId === record.projectId && saved.accountScopeId === record.accountScopeId);
212
+ base = acknowledgeReviewBaseline(base, record, receipt, options.reviewRunIds || []);
213
+ return { record: clean(record), base };
214
+ });
198
215
  const transfer = await Transfer.encode(entries);
199
216
  const written = await request(transfer.method, value, { ...transfer.payload, reviewRunIds: options.reviewRunIds || [] });
200
217
  // Keep the caller's submitted view as its merge baseline. A merged
201
218
  // remote change has not necessarily been applied to the visible UI.
202
- for (const entry of entries) baselines.set(cacheKey(value, entry.record.id), clean(entry.record));
219
+ if (options.adopt !== false)
220
+ for (const entry of entries) baselines.set(cacheKey(value, entry.record.id), clean(entry.record));
203
221
  return written;
204
222
  });
205
223
  }
206
- async function get(id) {
224
+ async function get(id, options = {}) {
207
225
  const value = capture();
208
226
  await migrate(value);
209
227
  const record = await request('get', value, { id });
210
- return tag(record, value, !heldLocks.has(lockKey('state', value)));
228
+ return tag(record, value, options.adopt !== false && !heldLocks.has(lockKey('state', value)));
229
+ }
230
+ async function putSessionReviewState(record, runIds) {
231
+ const value = capture(record.projectId, record.accountScopeId);
232
+ const ids = new Set(runIds);
233
+ const assertOwners = () => {
234
+ assertScope(value);
235
+ for (const runId of ids) {
236
+ const owner = heldLocks.get(lockKey('review', { ...value, runId }));
237
+ if (!owner || owner.lost) throw failure('storage_lock_lost', 'The shared review owner disconnected.');
238
+ }
239
+ };
240
+ assertOwners();
241
+ return withWrite(value, async () => {
242
+ assertOwners();
243
+ const latest = await request('get', value, { id: record.id });
244
+ if (!latest) throw failure('shared_session_deleted', 'The session no longer exists.');
245
+ if (latest.projectId !== value.projectId || latest.accountScopeId !== value.accountScopeId)
246
+ throw failure('shared_store_scope_mismatch', 'The session does not belong to the captured account and project.');
247
+ const proposed = new Map((record.runs || []).map(run => [run.id, run]));
248
+ const identity = run => JSON.stringify(['id', 'runProjectId', 'runAccountScopeId', 'startedAt', 'nativeEventSeq',
249
+ 'codexThreadId', 'codexTurnId', 'executionSnapshot', 'forkSnapshot'].map(key => run[key] ?? null));
250
+ for (const id of ids) {
251
+ const current = latest.runs?.find(run => run.id === id), incoming = proposed.get(id);
252
+ if (!current || !incoming || incoming.runProjectId !== value.projectId
253
+ || (incoming.runAccountScopeId && incoming.runAccountScopeId !== value.accountScopeId)
254
+ || identity(current) !== identity(incoming))
255
+ throw failure('review_context_changed', 'The shared review run is no longer available in its original scope.');
256
+ }
257
+ const next = { ...clean(latest), runs: latest.runs.map(run => {
258
+ if (!ids.has(run.id) || run.undoStatus === 'applied' || ['accepted', 'rejected'].includes(run.trackedChangeStatus)) return run;
259
+ return legacy.copyRunReviewState(run, proposed.get(run.id));
260
+ }) };
261
+ const transfer = await Transfer.encode([{ record: next, base: clean(latest) }]);
262
+ assertOwners();
263
+ await request(transfer.method, value, { ...transfer.payload, reviewRunIds: [...ids] });
264
+ const saved = await request('get', value, { id: record.id });
265
+ assertOwners();
266
+ if (!saved || saved.projectId !== value.projectId || saved.accountScopeId !== value.accountScopeId
267
+ || [...ids].some(id => {
268
+ const run = saved.runs?.find(run => run.id === id);
269
+ return !run || identity(run) !== identity(proposed.get(id))
270
+ || legacy.reviewStateSnapshot(run) !== legacy.reviewStateSnapshot(proposed.get(id));
271
+ })) throw failure('review_state_not_persisted', 'The review outcome was not durably saved. Check Overleaf before retrying.');
272
+ // A later envelope retry must also recognize the review already saved,
273
+ // without adopting any peer envelope fields from this readback.
274
+ const key = cacheKey(value, record.id), base = baselines.get(key);
275
+ if (base) baselines.set(key, acknowledgeReviewBaseline(base, record, saved, [...ids]));
276
+ return saved;
277
+ });
211
278
  }
212
279
  async function erase(method, projectId = '', id, accountScopeId) {
213
280
  const value = capture(projectId, accountScopeId);
@@ -228,7 +295,7 @@
228
295
  return record;
229
296
  });
230
297
  route('getAllSessions', () => list());
231
- route('getRecord', (store, id) => store === 'sessions' ? get(id) : api.getRecord(store, id));
298
+ route('getRecord', (store, id, options) => store === 'sessions' ? get(id, options) : api.getRecord(store, id));
232
299
  route('getAllByIndex', async (store, index, value) => {
233
300
  if (store !== 'sessions') return api.getAllByIndex(store, index, value);
234
301
  const records = await list(index === 'projectId' ? value : '');
@@ -236,10 +303,10 @@
236
303
  });
237
304
  route('claimSessionsForAccount', async (projectId, accountScopeId, deleted = []) => {
238
305
  capture(projectId, accountScopeId);
239
- return (await list(projectId)).filter(record => !deleted.includes(record.id));
306
+ return (await list(projectId, true)).filter(record => !deleted.includes(record.id));
240
307
  });
241
- route('putRecord', async (store, record) =>
242
- store === 'sessions' ? (await put([record]))[0] : api.putRecord(store, record));
308
+ route('putRecord', async (store, record, options) =>
309
+ store === 'sessions' ? (await put([record], options))[0] : api.putRecord(store, record));
243
310
  route('putRecords', (store, records, options) =>
244
311
  store === 'sessions' ? put(records, options) : api.putRecords(store, records));
245
312
  route('deleteRecord', (store, id, expectedScope) =>
@@ -280,7 +347,8 @@
280
347
  readSharedProjectSnapshot: projectId => list(projectId, false),
281
348
  adoptSharedSessionBaseline: adopt,
282
349
  hasUnpersistedSharedSession: hasLocalChanges,
283
- wasSharedSessionStored: wasStored
350
+ wasSharedSessionStored: wasStored,
351
+ putSessionReviewState
284
352
  });
285
353
  return wrapped;
286
354
  }
@@ -7,6 +7,63 @@
7
7
  })(typeof globalThis !== 'undefined' ? globalThis : window, function staleGuardFactory() {
8
8
  'use strict';
9
9
 
10
+ const DOCUMENT_PROVENANCE_OTHER_MIN = 0.9;
11
+ const DOCUMENT_PROVENANCE_CLAIMED_MAX = 0.5;
12
+ const DOCUMENT_PROVENANCE_EDGE_LIMIT = 64 * 1024;
13
+
14
+ function assessDocumentProvenance(input = {}) {
15
+ const { path, projectId, documentIdentity } = input || {};
16
+ if (documentIdentity !== undefined) {
17
+ const valid = documentIdentity && typeof documentIdentity === 'object' && !Array.isArray(documentIdentity)
18
+ && documentIdentity.version === 1 && documentIdentity.source === 'cm-ranges-v1'
19
+ && typeof projectId === 'string' && projectId.length > 0 && documentIdentity.projectId === projectId
20
+ && typeof path === 'string' && path.length > 0 && documentIdentity.path === path
21
+ && typeof documentIdentity.documentId === 'string' && typeof documentIdentity.targetDocumentId === 'string'
22
+ && /^[a-f0-9]{24}$/.test(documentIdentity.documentId)
23
+ && /^[a-f0-9]{24}$/.test(documentIdentity.targetDocumentId)
24
+ && documentIdentity.documentId === documentIdentity.targetDocumentId;
25
+ return valid ? { ok: true, identityState: 'match' }
26
+ : { ok: false, code: 'document_identity_mismatch', identityState: 'mismatch' };
27
+ }
28
+ try {
29
+ const { path, text, references } = input || {};
30
+ if (typeof text !== 'string' || !references) return { ok: true, identityState: 'fallback' };
31
+ const entries = references instanceof Map ? references : Object.entries(references);
32
+ const claimed = references instanceof Map ? references.get(path)
33
+ : Object.prototype.hasOwnProperty.call(references, path) ? references[path] : undefined;
34
+ if (text === claimed) return { ok: true, identityState: 'fallback' };
35
+ let matchedPath, bestScore = -1;
36
+ for (const [otherPath, reference] of entries) {
37
+ if (otherPath === path || typeof reference !== 'string') continue;
38
+ if (text === reference) return { ok: false, code: 'document_identity_mismatch', identityState: 'fallback', matchedPath: otherPath };
39
+ const score = documentSimilarity(text, reference);
40
+ if (score > bestScore) { bestScore = score; matchedPath = otherPath; }
41
+ }
42
+ if (bestScore >= DOCUMENT_PROVENANCE_OTHER_MIN
43
+ && (typeof claimed !== 'string' || documentSimilarity(text, claimed) < DOCUMENT_PROVENANCE_CLAIMED_MAX)) {
44
+ return { ok: false, code: 'document_identity_mismatch', identityState: 'fallback', matchedPath };
45
+ }
46
+ } catch (_error) {
47
+ // Unavailable references supply no contradictory evidence.
48
+ }
49
+ return { ok: true, identityState: 'fallback' };
50
+ }
51
+
52
+ // Shared prefix + non-overlapping suffix / longer length. For large files,
53
+ // sample at most 64 Ki UTF-16 units at each edge and cap the denominator at
54
+ // twice that limit. Exact equality above still compares the complete text.
55
+ // No token arrays or quadratic diff: total work is O(total reference text),
56
+ // with bounded per-reference similarity work and constant auxiliary memory.
57
+ function documentSimilarity(left, right) {
58
+ const length = Math.min(left.length, right.length);
59
+ let prefix = 0, suffix = 0;
60
+ while (prefix < Math.min(length, DOCUMENT_PROVENANCE_EDGE_LIMIT) && left[prefix] === right[prefix]) prefix++;
61
+ while (suffix < Math.min(length - prefix, DOCUMENT_PROVENANCE_EDGE_LIMIT)
62
+ && left[left.length - suffix - 1] === right[right.length - suffix - 1]) suffix++;
63
+ const denominator = Math.min(Math.max(left.length, right.length), 2 * DOCUMENT_PROVENANCE_EDGE_LIMIT);
64
+ return denominator ? (prefix + suffix) / denominator : 1;
65
+ }
66
+
10
67
  function buildBaseFileLookup(files) {
11
68
  if (!Array.isArray(files) || files.length === 0) {
12
69
  return null;
@@ -45,72 +102,62 @@
45
102
  const baseContent = normalizeText(baseFileLookup.get(filePath));
46
103
  const current = normalizeText(currentContent);
47
104
  if (current !== baseContent) {
48
- if (typeof console !== 'undefined') {
49
- console.warn('[codex-overleaf] staleGuard: STALE', filePath,
50
- 'base.length:', baseContent.length, 'current.length:', current.length,
51
- 'base[0..80]:', JSON.stringify(baseContent.slice(0, 80)),
52
- 'current[0..80]:', JSON.stringify(current.slice(0, 80)));
105
+ if (Array.isArray(operation.patches) && operation.patches.length) {
106
+ const preparedPatches = prepareOperationPatches(operation, currentContent, baseFileLookup);
107
+ if (preparedPatches.ok) return { ok: true, reconciled: true, strategy: 'change-envelope', preparedPatches };
53
108
  }
54
- const patchRangeFreshness = checkPatchRangeFreshness(operation, currentContent, baseFileLookup);
55
- if (patchRangeFreshness) {
56
- return patchRangeFreshness;
57
- }
58
- return {
59
- ok: false,
60
- code: 'stale_snapshot',
61
- reasonKey: 'staleSnapshot',
62
- reasonParams: { filePath },
63
- reason: `${filePath} 在任务执行期间被你或协作者改过,Codex 没有覆盖它。请查看差异后重试。`
64
- };
109
+ return staleSourceChanged(filePath);
65
110
  }
66
111
 
67
112
  return { ok: true };
68
113
  }
69
114
 
70
- function checkPatchRangeFreshness(operation, currentContent, baseFileLookup) {
71
- const prepared = prepareOperationPatches(operation, currentContent, baseFileLookup);
72
- if (!prepared.ok) {
73
- return { ...prepared, code: 'stale_patch_range', reasonKey: 'stalePatchConflict' };
74
- }
75
- const patches = prepared.patches;
76
- if (!patches.length) {
77
- return null;
78
- }
79
-
80
- const current = String(currentContent ?? '');
81
- for (const rawPatch of patches) {
82
- const from = Number(rawPatch?.from);
83
- const to = Number(rawPatch?.to);
84
- const expected = String(rawPatch?.expected ?? '');
85
- if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > current.length) {
86
- return {
87
- ok: false,
88
- code: 'stale_patch_range',
89
- reasonKey: 'stalePatchLocation',
90
- reason: 'Codex 要修改的位置已经无法和当前 Overleaf 内容对齐,所以没有写入。请重新运行任务。'
91
- };
92
- }
93
- if (current.slice(from, to) !== expected) {
94
- return {
95
- ok: false,
96
- code: 'stale_patch_range',
97
- reasonKey: 'stalePatchConflict',
98
- reason: 'Codex 要修改的具体位置已经被你或协作者改过,所以没有覆盖它。请查看差异后重试。'
99
- };
100
- }
101
- }
102
-
115
+ function staleSourceChanged(filePath) {
103
116
  return {
104
- ok: true,
105
- reconciled: true,
106
- strategy: 'patch-range'
117
+ ok: false,
118
+ code: 'stale_snapshot',
119
+ reasonKey: 'staleSnapshot',
120
+ reasonParams: { filePath },
121
+ reason: `${filePath} 在任务执行期间被你或协作者改过,Codex 没有覆盖它。请查看差异后重试。`
107
122
  };
108
123
  }
109
124
 
110
- // Bridge the raw Native baseline to the editor's line-ending representation.
111
- // Logical LF offsets are only an intermediate coordinate system: both the
112
- // range check and the eventual editor dispatch use the same raw patches.
125
+ // Rebase only ranges wholly in a common prefix/suffix. All patches use the
126
+ // same envelope before any of them are applied to the editor.
113
127
  function prepareOperationPatches(operation, currentContent, baseFileLookup) {
128
+ const path = normalizePath(operation?.path), base = baseFileLookup?.get(path);
129
+ const current = String(currentContent ?? '');
130
+ const envelope = typeof base === 'string' ? changedEnvelope(normalizeText(base), normalizeText(current)) : null;
131
+ const prepared = mapOperationPatches(operation, current, baseFileLookup, envelope);
132
+ return !prepared.ok && envelope ? staleSourceChanged(path) : prepared;
133
+ }
134
+
135
+ function changedEnvelope(base, current) {
136
+ if (base === current) return null;
137
+ let prefix = 0, suffix = 0;
138
+ while (prefix < base.length && prefix < current.length && base[prefix] === current[prefix]) prefix++;
139
+ while (suffix < base.length - prefix && suffix < current.length - prefix
140
+ && base[base.length - suffix - 1] === current[current.length - suffix - 1]) suffix++;
141
+ return { prefix, end: base.length - suffix, delta: current.length - base.length };
142
+ }
143
+
144
+ function mapEnvelopeRange(from, to, envelope, insertion = from === to) {
145
+ if (!envelope) return { from, to };
146
+ if (insertion && (from === envelope.prefix || from === envelope.end)) return null;
147
+ if (to <= envelope.prefix) return { from, to };
148
+ if (from >= envelope.end) return { from: from + envelope.delta, to: to + envelope.delta };
149
+ return null;
150
+ }
151
+
152
+ // Historical checkpoints already refer to observed applied offsets. This
153
+ // compatibility converter never authorizes a new forward write or rebase.
154
+ function prepareStoredInversePatches(operation, currentContent, baseFileLookup) {
155
+ return mapOperationPatches(operation, currentContent, baseFileLookup, null);
156
+ }
157
+
158
+ // Logical LF offsets bridge raw Native and editor coordinates. Expand split
159
+ // CRLF edges before dispatch; keep unchanged editor line endings intact.
160
+ function mapOperationPatches(operation, currentContent, baseFileLookup, envelope) {
114
161
  const patches = Array.isArray(operation?.patches) ? operation.patches : [];
115
162
  const current = String(currentContent ?? '');
116
163
  const path = normalizePath(operation?.path);
@@ -120,8 +167,21 @@
120
167
  }
121
168
  const baseMap = lineEndingMap(base);
122
169
  const currentMap = lineEndingMap(current);
123
- // With no CRLF on either side, retain the existing raw range semantics.
124
- if (!baseMap.removed.length && !currentMap.removed.length) return { ok: true, patches };
170
+ if (!baseMap.removed.length && !currentMap.removed.length) {
171
+ if (!envelope) return { ok: true, patches };
172
+ const mapped = [];
173
+ let previousTo = 0;
174
+ for (const patch of patches) {
175
+ const from = Number(patch?.from), to = Number(patch?.to), expected = String(patch?.expected ?? '');
176
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < previousTo || to < from || to > base.length
177
+ || base.slice(from, to) !== expected) return patchMappingFailure('stale_patch');
178
+ previousTo = to;
179
+ const range = mapEnvelopeRange(from, to, envelope);
180
+ if (!range || current.slice(range.from, range.to) !== expected) return patchMappingFailure('stale_patch');
181
+ mapped.push({ ...range, expected, insert: String(patch.insert ?? '') });
182
+ }
183
+ return { ok: true, patches: mapped };
184
+ }
125
185
 
126
186
  const groups = [];
127
187
  let previousTo = 0;
@@ -132,6 +192,11 @@
132
192
  }
133
193
  if (base.slice(from, to) !== String(patch.expected ?? '')) return patchMappingFailure('stale_patch');
134
194
  previousTo = to;
195
+ // Check each insertion boundary even if CRLF expansion later merges it
196
+ // with an adjacent nonempty range.
197
+ if (!mapEnvelopeRange(baseMap.toLogical(from), baseMap.toLogical(to), envelope, from === to)) {
198
+ return patchMappingFailure('stale_patch');
199
+ }
135
200
  // Expand only a split CRLF or a new pair formed across an edit edge.
136
201
  // Other neighbouring newlines belong to the editor and stay untouched.
137
202
  let start = from, end = to;
@@ -147,9 +212,9 @@
147
212
 
148
213
  const mapped = [], sourceChanges = [];
149
214
  for (const group of groups) {
150
- const logicalFrom = baseMap.toLogical(group.start), logicalTo = baseMap.toLogical(group.end);
151
- if (logicalTo > currentMap.length) return patchMappingFailure('invalid_patch');
152
- const from = currentMap.toRaw(logicalFrom), to = currentMap.toRaw(logicalTo);
215
+ const range = mapEnvelopeRange(baseMap.toLogical(group.start), baseMap.toLogical(group.end), envelope);
216
+ if (!range || range.from < 0 || range.to > currentMap.length) return patchMappingFailure('stale_patch');
217
+ const from = currentMap.toRaw(range.from), to = currentMap.toRaw(range.to);
153
218
  const expected = current.slice(from, to);
154
219
  if (normalizeText(expected) !== normalizeText(base.slice(group.start, group.end))) {
155
220
  return patchMappingFailure('stale_patch');
@@ -173,13 +238,13 @@
173
238
  while (oldEnd > prefix && newEnd > prefix && expected[oldEnd - 1] === insert[newEnd - 1]) { oldEnd -= 1; newEnd -= 1; }
174
239
  mapped.push({ from: from + prefix, to: from + oldEnd, expected: expected.slice(prefix, oldEnd), insert: insert.slice(prefix, newEnd) });
175
240
  }
176
- // A later operation in the same batch still addresses the original
177
- // representation, so advance its baseline separately from editor text.
241
+ // Preserve source coordinates for line-ending-only conversion. A rebased
242
+ // entry instead advances to its actual verified editor post-image.
178
243
  let sourceContent = base;
179
244
  for (const patch of sourceChanges.reverse()) {
180
245
  sourceContent = sourceContent.slice(0, patch.from) + patch.insert + sourceContent.slice(patch.to);
181
246
  }
182
- return { ok: true, patches: mapped, sourceContent };
247
+ return { ok: true, patches: mapped, ...(!envelope ? { sourceContent } : {}) };
183
248
  }
184
249
 
185
250
  function patchMappingFailure(code) {
@@ -254,9 +319,11 @@
254
319
  }
255
320
 
256
321
  return {
322
+ assessDocumentProvenance,
257
323
  buildBaseFileLookup,
258
324
  checkOperationFreshness,
259
325
  prepareOperationPatches,
326
+ prepareStoredInversePatches,
260
327
  updateExpectedFileContent,
261
328
  removeExpectedFile,
262
329
  moveExpectedFile,