codex-overleaf-link 2.6.0 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +9 -9
  2. package/README.zh-CN.md +9 -9
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +70 -0
  5. package/extension/src/content/composerAttachments.js +35 -8
  6. package/extension/src/content/contentRuntime.js +200 -51
  7. package/extension/src/content/diagnosticsController.js +77 -34
  8. package/extension/src/content/diagnosticsExport.js +1 -1
  9. package/extension/src/content/diagnosticsPanel.js +129 -42
  10. package/extension/src/content/generated/content.bundle.js +1283 -331
  11. package/extension/src/content/generated/content.bundle.meta.json +3 -3
  12. package/extension/src/content/localSkillsPanel.js +4 -2
  13. package/extension/src/content/markdownText.js +11 -9
  14. package/extension/src/content/modelPicker.js +28 -19
  15. package/extension/src/content/nativeCompatibilityController.js +23 -9
  16. package/extension/src/content/pageBridgeClient.js +5 -5
  17. package/extension/src/content/panelMaintenance.js +4 -0
  18. package/extension/src/content/panelRenderer.js +4 -1
  19. package/extension/src/content/providerSettingsDialog.js +26 -18
  20. package/extension/src/content/recentProjects.js +26 -16
  21. package/extension/src/content/runQueueScheduler.js +19 -9
  22. package/extension/src/content/runResultActions.js +51 -4
  23. package/extension/src/content/runReviewController.js +9 -3
  24. package/extension/src/content/scopedPersistencePanelState.js +34 -25
  25. package/extension/src/content/selectionContextView.js +30 -3
  26. package/extension/src/content/sharedSessionViewSync.js +9 -1
  27. package/extension/src/content/writebackOrchestrator.js +266 -50
  28. package/extension/src/content/writingStyleSettings.js +70 -17
  29. package/extension/src/page/overleafEditor.js +12 -3
  30. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  31. package/extension/src/page/snapshotRouter.js +75 -66
  32. package/extension/src/page/treeOperations.js +200 -19
  33. package/extension/src/page/writebackRouter.js +152 -40
  34. package/extension/src/pageBridge.js +17 -7
  35. package/extension/src/shared/agentTranscript.js +23 -2
  36. package/extension/src/shared/auditRecords.js +1 -0
  37. package/extension/src/shared/compatibility.js +1 -1
  38. package/extension/src/shared/composerAttachmentDraft.js +22 -7
  39. package/extension/src/shared/failureReasons.js +15 -0
  40. package/extension/src/shared/i18n.js +16 -0
  41. package/extension/src/shared/pageRpcContract.js +1 -1
  42. package/extension/src/shared/projectFiles.js +2 -1
  43. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  44. package/extension/src/shared/staleGuard.js +58 -0
  45. package/extension/src/shared/storageMigration.js +104 -1
  46. package/extension/styles/panel.css +191 -63
  47. package/native-host/src/codexSessionRunner.js +1 -0
  48. package/native-host/src/debugLog.js +27 -3
  49. package/native-host/src/index.js +42 -11
  50. package/native-host/src/localSkills.js +1 -1
  51. package/native-host/src/mirrorWorkspace.js +47 -3
  52. package/native-host/src/nativeEnvironment.js +27 -5
  53. package/native-host/src/providerRuntime.js +1 -0
  54. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  55. package/native-host/src/taskRunnerRuntime.js +11 -3
  56. package/native-host/src/updateManager.js +24 -6
  57. package/native-host/src/writingStyleRuntime.js +1 -1
  58. package/package.json +1 -1
@@ -697,6 +697,8 @@
697
697
  showPluginConfirm, formatOperationFiles, getAppliedEntries, getSkippedEntries
698
698
  });
699
699
  const writebackOrchestrator = Modules.WritebackOrchestrator.create({
700
+ mirrorFenceStore: root.CodexOverleafModuleRegistry.resolve('ScopedPersistenceBrowserAdapter'),
701
+ getAccountScopeId: () => cachedAccountScopeId,
700
702
  tr,
701
703
  tx,
702
704
  getLocale,
@@ -844,20 +846,19 @@
844
846
  });
845
847
  // v1.8.1 D3: the dashboard was a one-shot render — another tab finishing a
846
848
  // run, clearing history or creating sessions never showed up. Refresh it
847
- // (debounced) when the tab regains visibility or a cross-tab run signal
848
- // arrives while the dashboard view is active.
849
+ // (debounced) on visibility, cross-tab run signals, and shared-history changes.
849
850
  let dashboardRefreshTimer = null;
850
851
  function refreshDashboardSoon() {
851
- if (panel?.dataset.view !== 'recent-projects') {
852
+ if (panel?.dataset.view !== 'recent-projects' || document.visibilityState !== 'visible') {
852
853
  return;
853
854
  }
854
855
  clearTimeout(dashboardRefreshTimer);
855
856
  dashboardRefreshTimer = setTimeout(() => {
856
- if (panel?.dataset.view !== 'recent-projects') {
857
+ if (panel?.dataset.view !== 'recent-projects' || document.visibilityState !== 'visible') {
857
858
  return;
858
859
  }
859
860
  // Never clobber an in-progress inline rename (fleet P2) — retry later.
860
- if (panel.querySelector('.recent-projects-sessions input')) {
861
+ if (panel.querySelector('.recent-projects-session-rename-input')) {
861
862
  refreshDashboardSoon();
862
863
  return;
863
864
  }
@@ -865,11 +866,7 @@
865
866
  const expanded = Array.from(panel.querySelectorAll('[data-project-row-wrap][data-expanded="true"]'))
866
867
  .map(wrap => wrap.getAttribute('data-project-row-wrap'))
867
868
  .filter(Boolean);
868
- const rootEl = panel.querySelector('[data-recent-projects-root]');
869
- recentProjects.renderRecentProjectsVariant({
870
- restoreExpanded: expanded,
871
- restoreScrollTop: rootEl ? rootEl.scrollTop : undefined
872
- }).catch(() => { /* keep last render */ });
869
+ recentProjects.refreshDashboardView({ restoreExpanded: expanded }).catch(() => { /* keep last render */ });
873
870
  }, 400);
874
871
  }
875
872
  document.addEventListener('visibilitychange', () => {
@@ -1458,7 +1455,7 @@
1458
1455
  primeCodexLocalSkillsCatalog().catch(() => { /* retry through settings, slash, or submission */ });
1459
1456
  }
1460
1457
 
1461
- PanelRenderer.setVisible(panel, true);
1458
+ PanelRenderer.setVisible(panelRendererInstance, true);
1462
1459
  }
1463
1460
 
1464
1461
  // Panel prefetch and first submission share the same official catalog read.
@@ -2153,9 +2150,11 @@
2153
2150
  }
2154
2151
 
2155
2152
  let focusFiles = [...executionSnapshot.focusFiles];
2153
+ const mirrorOwner = currentRunView, mirrorRouteGeneration = spaRouteGeneration;
2156
2154
  const warmStart = await awaitRunStep(resolveWarmRunStart({ focusFiles, mode: submittedMode }));
2155
+ const mirrorFence = await awaitRunStep(writebackOrchestrator.refreshMirrorReuseFence(mirrorOwner.runProjectId, mirrorOwner.runAccountScopeId));
2157
2156
  let project = warmStart.project || null;
2158
- let useExistingMirror = warmStart.useExistingMirror;
2157
+ let useExistingMirror = warmStart.useExistingMirror && !mirrorFence;
2159
2158
  let fileOverlays = warmStart.fileOverlays || null;
2160
2159
  let otWarmStart = warmStart.otWarmStart === true;
2161
2160
  if (!useExistingMirror) {
@@ -2164,6 +2163,18 @@
2164
2163
  status: 'running'
2165
2164
  });
2166
2165
  project = await awaitRunStep(getRunProjectSnapshot());
2166
+ if (mirrorFence) {
2167
+ const assertCurrent = () => {
2168
+ throwIfRunCancellationRequested();
2169
+ if (mirrorOwner !== currentRunView || mirrorRouteGeneration !== spaRouteGeneration
2170
+ || mirrorOwner.runProjectId !== getCurrentProjectId()
2171
+ || mirrorOwner.runAccountScopeId !== cachedAccountScopeId) throw createRunCancellationError();
2172
+ };
2173
+ await awaitRunStep(writebackOrchestrator.syncFencedMirror(mirrorOwner.runProjectId, project, {
2174
+ fence: mirrorFence, assertCurrent,
2175
+ sendNative: payload => nativeCompatibilityController.sendBackgroundNative(payload, { assertCurrent })
2176
+ }));
2177
+ }
2167
2178
  }
2168
2179
  throwIfRunCancellationRequested();
2169
2180
  if (!useExistingMirror) {
@@ -2332,8 +2343,8 @@
2332
2343
  submittedMode
2333
2344
  })));
2334
2345
 
2335
- // Handle mirror_stale error by retrying with full sync
2336
- if (!response.ok && response.error?.code === 'mirror_stale' && useExistingMirror) {
2346
+ // A stale mirror or contradictory editor overlay requires a fresh read.
2347
+ if (!response.ok && ['mirror_stale', 'document_identity_mismatch'].includes(response.error?.code) && useExistingMirror) {
2337
2348
  appendRunEvent({ title: tr('warmMirrorStaleRetryTitle'), status: 'running' });
2338
2349
  const staleRetry = await awaitRunStep(prepareMirrorStaleRetry({
2339
2350
  project: await awaitRunStep(getRunProjectSnapshot()),
@@ -2601,7 +2612,7 @@
2601
2612
  });
2602
2613
  runSettlementPersistence.applyToCurrentRun(syncOutcome.settlement);
2603
2614
  if (runCancellationRequested) {
2604
- throw createRunCancellationError();
2615
+ throw Object.assign(createRunCancellationError(), { writebackOutcome: syncOutcome });
2605
2616
  }
2606
2617
  await finalizeAuditRecord(runAuditDraft, {
2607
2618
  ...(syncOutcome.audit || {}),
@@ -2712,7 +2723,10 @@
2712
2723
  changedDocument: false, evidence: { declinedChoice: true }
2713
2724
  }),
2714
2725
  nextStep: tr('failureReason_reviewing_unavailable_cancelled_next') });
2715
- } else appendRunCancelledReport();
2726
+ } else {
2727
+ runSettlementPersistence.applyToCurrentRun(error.writebackOutcome?.settlement);
2728
+ appendRunCancelledReport(error.writebackOutcome);
2729
+ }
2716
2730
  await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
2717
2731
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2718
2732
  return;
@@ -2800,9 +2814,15 @@
2800
2814
  nativeChannel.clearActiveRequest();
2801
2815
  stopRunElapsedTick();
2802
2816
  await flushQueuedSaveState().catch(() => {});
2817
+ // The card is already settled. Retain the owner while Native stops so
2818
+ // neither ordinary promotion nor a deferred session start races its lock.
2819
+ if (settlement.continueQueue) {
2820
+ settlement.continueQueue = await waitForCancelledNativeRun(settledView);
2821
+ }
2803
2822
  currentRunView = null;
2804
2823
  runCancellationRequested = false;
2805
2824
  runCancellationController = null;
2825
+ syncComposerSendAvailability();
2806
2826
  activeTurnControl.release(settlement.requestId);
2807
2827
  if (isExperimentalOtEnabled()) {
2808
2828
  try { await resumeOtWarmMirror('run-settled'); }
@@ -3240,7 +3260,7 @@
3240
3260
  if (state?.activeSessionId !== sessionId || !isProjectEditorRoute(window.location)) return false;
3241
3261
  if (sessionManager.isTransitioning()) {
3242
3262
  deferredSessionStart = { sessionId, scope: getComposerDraftScope() };
3243
- return false;
3263
+ return 'session_transition';
3244
3264
  }
3245
3265
  return true;
3246
3266
  }
@@ -3286,18 +3306,21 @@
3286
3306
  // codex_cancelled instead of grinding through the rest of the queue.
3287
3307
  // Without the page-side signal, a cancel during writeback waits up to
3288
3308
  // the applyOperations content-side timeout (30s) before the run settles.
3289
- const activeRequestId = nativeChannel.getActiveRequestId();
3309
+ const activeRequestId = currentRunView.nativeRequestId;
3290
3310
  const projectKey = currentRunView?.runProjectId || getCurrentProjectId() || '';
3291
3311
 
3292
3312
  const cancelTargets = [];
3293
- if (activeRequestId || projectKey) {
3313
+ if (activeRequestId != null && activeRequestId !== '') {
3294
3314
  cancelTargets.push(sendBackgroundNative({
3295
3315
  method: 'codex.cancel',
3296
3316
  params: {
3297
- requestId: activeRequestId || undefined,
3317
+ requestId: activeRequestId,
3298
3318
  projectKey: projectKey || undefined
3299
3319
  }
3300
3320
  }));
3321
+ } else {
3322
+ // Preparation has no Native run to cancel; the local signal prevents dispatch.
3323
+ cancelTargets.push(Promise.resolve(null));
3301
3324
  }
3302
3325
  // The page-side cancel is best-effort — it only matters if a writeback
3303
3326
  // is currently in flight. Swallow errors / unavailable bridge so the
@@ -3384,6 +3407,23 @@
3384
3407
  });
3385
3408
  }
3386
3409
 
3410
+ async function waitForCancelledNativeRun(runView) {
3411
+ const pending = runView?.pendingNativeRun;
3412
+ if (!pending) return true;
3413
+ const CANCELLED_NATIVE_SETTLEMENT_TIMEOUT_MS = 20000;
3414
+ let timer;
3415
+ try {
3416
+ return await Promise.race([
3417
+ pending.then(() => true, () => true),
3418
+ new Promise(resolve => {
3419
+ timer = setTimeout(() => resolve(false), CANCELLED_NATIVE_SETTLEMENT_TIMEOUT_MS);
3420
+ })
3421
+ ]);
3422
+ } finally {
3423
+ clearTimeout(timer);
3424
+ }
3425
+ }
3426
+
3387
3427
  function isRunCancellationError(error = {}) {
3388
3428
  return error.code === 'codex_cancelled' || /cancelled by the user|was cancelled/i.test(error.message || '');
3389
3429
  }
@@ -3547,21 +3587,26 @@
3547
3587
  method: 'codex.run',
3548
3588
  params
3549
3589
  }, compatibilityGate.compatibility));
3550
- if (currentRunView) {
3551
- currentRunView.nativeRequestId = tracked.id;
3552
- const record = findRunRecord(currentRunView.recordId, currentRunView.sessionId);
3590
+ if (owner) {
3591
+ owner.nativeRequestId = tracked.id;
3592
+ // Keep the actual response separate from awaitRunStep's local abort race.
3593
+ owner.pendingNativeRun = tracked.promise;
3594
+ const record = findRunRecord(owner.recordId, owner.sessionId);
3553
3595
  if (record) {
3554
3596
  record.nativeRequestId = tracked.id;
3555
3597
  }
3556
3598
  activeTurnControl.bind({
3557
3599
  requestId: tracked.id,
3558
- projectKey: currentRunView.runProjectId,
3559
- clientRunId: currentRunView.recordId,
3560
- sessionId: currentRunView.sessionId
3600
+ projectKey: owner.runProjectId,
3601
+ clientRunId: owner.recordId,
3602
+ sessionId: owner.sessionId
3561
3603
  });
3562
3604
  saveStateSoon();
3563
3605
  }
3564
3606
  return tracked.promise.finally(() => {
3607
+ if (owner?.pendingNativeRun === tracked.promise) {
3608
+ owner.pendingNativeRun = null;
3609
+ }
3565
3610
  if (currentRunView?.nativeRequestId === tracked.id) {
3566
3611
  currentRunView.activeTurn = null;
3567
3612
  renderPendingInputs();
@@ -3704,8 +3749,8 @@
3704
3749
  }).run(options);
3705
3750
  }
3706
3751
 
3707
- function appendRunCancelledReport() {
3708
- const hasQueuedFollowUp = (getActiveSession(state)?.pendingInputs || [])
3752
+ function appendRunCancelledReport(writebackOutcome = {}, continueQueue = true) {
3753
+ const hasQueuedFollowUp = continueQueue && (getActiveSession(state)?.pendingInputs || [])
3709
3754
  .some(item => item?.status === 'queued');
3710
3755
  appendRunEvent({
3711
3756
  title: tx('Current Codex task was cancelled.', '已中断当前 Codex 任务。'),
@@ -3715,8 +3760,9 @@
3715
3760
  appendCompletionReport({ conclusionKey: 'cancelled',
3716
3761
  conclusion: tx('This run was cancelled. It did not continue syncing or writing to Overleaf.', '这轮已中断,没有继续同步或写入 Overleaf。'),
3717
3762
  status: 'cancelled',
3718
- operations: [],
3719
- applyResults: [],
3763
+ operations: writebackOutcome.operations || [],
3764
+ applyResults: writebackOutcome.applied ? [writebackOutcome.applied] : [],
3765
+ saveVerification: writebackOutcome.saveVerification,
3720
3766
  nextStep: hasQueuedFollowUp
3721
3767
  ? tx('The first queued follow-up will start next.', '接下来会自动启动队首的后续任务。')
3722
3768
  : tx('Edit the task and run again.', '可以修改任务后重新运行。')
@@ -3741,17 +3787,21 @@
3741
3787
  const running = typeof runningOverride === 'boolean'
3742
3788
  ? runningOverride
3743
3789
  : Boolean(currentRunView);
3790
+ const runningInActiveSession = running && (!currentRunView || currentRunView.sessionId === state?.activeSessionId);
3744
3791
  const transitioning = sessionManager.isTransitioning();
3745
- const action = running && (!hasInput || transitioning) ? 'cancel' : 'send';
3792
+ const action = runningInActiveSession && (!hasInput || transitioning) ? 'cancel' : 'send';
3793
+ const attachmentsLoading = composerAttachmentController.hasPendingReads();
3746
3794
  const label = action === 'cancel'
3747
3795
  ? tr('cancelRun')
3748
- : running
3749
- ? tr('queueNextInput')
3750
- : tr('send');
3796
+ : attachmentsLoading
3797
+ ? tr('attachmentsLoading')
3798
+ : running && hasInput
3799
+ ? tr('queueNextInput')
3800
+ : tr('send');
3751
3801
  runButton.dataset.action = action;
3752
3802
  runButton.disabled = action === 'cancel'
3753
3803
  ? runCancellationRequested
3754
- : !hasInput || transitioning;
3804
+ : !hasInput || transitioning || attachmentsLoading;
3755
3805
  runButton.title = label;
3756
3806
  runButton.setAttribute('aria-label', label);
3757
3807
  if (currentRunView?.writebackRetry && hasInput && action !== 'cancel') runButton.disabled = true;
@@ -3762,6 +3812,11 @@
3762
3812
  if (event.key === 'Enter' && !event.isComposing && event.keyCode !== 229) event.preventDefault();
3763
3813
  return;
3764
3814
  }
3815
+ if (composerAttachmentController.hasPendingReads() && event.key === 'Enter'
3816
+ && !event.shiftKey && !event.isComposing && event.keyCode !== 229) {
3817
+ event.preventDefault();
3818
+ return;
3819
+ }
3765
3820
  if (handleSlashMenuKeydown(event)) {
3766
3821
  return;
3767
3822
  }
@@ -3809,7 +3864,8 @@
3809
3864
  if (!isCodexOverleafSkillEnabled(id)) {
3810
3865
  continue;
3811
3866
  }
3812
- const title = String(skill?.title || skill?.name || id).trim().slice(0, 80) || id;
3867
+ const title = id === 'reviewer-response' ? tx('Reviewer response', '审稿回复')
3868
+ : String(skill?.title || skill?.name || id).trim().slice(0, 80) || id;
3813
3869
  commands.push({
3814
3870
  id: `skill:${id}`,
3815
3871
  kind: 'codex-overleaf-skill',
@@ -4529,6 +4585,8 @@
4529
4585
  Bridge: Modules.SharedSessionBridge, StorageDb: Modules.StorageDb,
4530
4586
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
4531
4587
  getState: () => state, setState: next => { state = next; },
4588
+ isDashboard: () => panel?.dataset.view === 'recent-projects',
4589
+ refreshDashboard: refreshDashboardSoon,
4532
4590
  // An in-flight Accept/Undo keeps its view until it settles; refresh applies afterwards.
4533
4591
  isBusy: () => Boolean(pendingDraftSave || readLiveRunViewForSaveStateGuard() || trackedChangeInFlight.size
4534
4592
  || runReviewController?.hasActiveReview?.() || document.querySelector('dialog[open]')),
@@ -5545,8 +5603,9 @@
5545
5603
  };
5546
5604
  }
5547
5605
 
5548
- async function getRunProjectSnapshot() {
5606
+ async function getRunProjectSnapshot(expectedProjectId = getCurrentProjectId()) {
5549
5607
  const project = await callPageBridge('getProjectSnapshot', {
5608
+ projectId: expectedProjectId,
5550
5609
  force: true,
5551
5610
  maxAgeMs: 0,
5552
5611
  preferLightweight: true,
@@ -5558,11 +5617,12 @@
5558
5617
  zipTimeoutMs: RUN_SNAPSHOT_ZIP_TIMEOUT_MS,
5559
5618
  focusFiles: getActiveFocusFiles()
5560
5619
  });
5561
- const sanitizedProject = sanitizeRunProjectSnapshot(project);
5620
+ const sanitizedProject = sanitizeRunProjectSnapshot(project, expectedProjectId);
5562
5621
  if (sanitizedProject?.capabilities?.fullProjectSnapshot || sanitizedProject?.capabilities?.pathIdentityComplete === false) {
5563
5622
  return sanitizedProject;
5564
5623
  }
5565
5624
  const pageStateProject = await callPageBridge('getProjectSnapshot', {
5625
+ projectId: expectedProjectId,
5566
5626
  force: true,
5567
5627
  maxAgeMs: 0,
5568
5628
  preferLightweight: true,
@@ -5572,17 +5632,21 @@
5572
5632
  includeBinaryFiles: false,
5573
5633
  focusFiles: getActiveFocusFiles()
5574
5634
  });
5575
- const sanitizedPageStateProject = sanitizeRunProjectSnapshot(pageStateProject);
5635
+ const sanitizedPageStateProject = sanitizeRunProjectSnapshot(pageStateProject, expectedProjectId);
5576
5636
  if (sanitizedPageStateProject?.capabilities?.fullProjectSnapshot) {
5577
5637
  return sanitizedPageStateProject;
5578
5638
  }
5579
5639
  return sanitizedProject;
5580
5640
  }
5581
5641
 
5582
- function sanitizeRunProjectSnapshot(project) {
5642
+ function sanitizeRunProjectSnapshot(project, expectedProjectId = project?.id) {
5583
5643
  if (!project || typeof project !== 'object') return project;
5644
+ if (project.id !== expectedProjectId) return { ...project, files: [], capabilities: { ...project.capabilities,
5645
+ fullProjectSnapshot: false, skipped: [{ path: '', reason: 'document_identity_mismatch' }] } };
5584
5646
  const normalize = Modules.ProjectFiles.normalizeSafeProjectPath;
5585
- const files = Array.isArray(project.files) ? project.files : [];
5647
+ const files = Array.isArray(project.files) ? project.files.map(file => file?.documentIdentity === undefined ? file
5648
+ : { ...file, documentIdentity: snapshotDocumentIdentity(file.documentIdentity, file.path, project.id) }) : [];
5649
+ project = { ...project, files };
5586
5650
  const counts = new Map();
5587
5651
  for (const file of files) {
5588
5652
  const path = normalize(file?.path);
@@ -5619,12 +5683,16 @@
5619
5683
  // --- Warm Mirror Controller ---
5620
5684
 
5621
5685
  async function getMirrorFreshness() {
5686
+ const projectId = getCurrentProjectId(), accountScopeId = cachedAccountScopeId;
5687
+ if (await writebackOrchestrator.refreshMirrorReuseFence(projectId, accountScopeId)) return null;
5688
+ if (getCurrentProjectId() !== projectId || cachedAccountScopeId !== accountScopeId) return null;
5622
5689
  try {
5623
5690
  const response = await sendBackgroundNative({
5624
5691
  method: 'mirror.status',
5625
- params: { projectId: getCurrentProjectId() }
5692
+ params: { projectId }
5626
5693
  });
5627
- if (response?.ok) {
5694
+ const fence = await writebackOrchestrator.refreshMirrorReuseFence(projectId, accountScopeId);
5695
+ if (response?.ok && !fence && getCurrentProjectId() === projectId && cachedAccountScopeId === accountScopeId) {
5628
5696
  return response.result;
5629
5697
  }
5630
5698
  } catch (error) { /* fall through */ }
@@ -5655,7 +5723,7 @@
5655
5723
  }
5656
5724
 
5657
5725
  const mirrorStatus = await getMirrorFreshness();
5658
- const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus);
5726
+ const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus || {});
5659
5727
  if (!mirrorStatus?.exists || !mirrorHealth.reusable) {
5660
5728
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
5661
5729
  }
@@ -5684,17 +5752,33 @@
5684
5752
  return candidates
5685
5753
  .filter(file =>
5686
5754
  typeof file.content === 'string' &&
5687
- Modules.ProjectFiles.isUsableProjectFileContent(file.content) &&
5755
+ (Modules.ProjectFiles.isUsableProjectFileContent(file.content) || file.content === '') &&
5688
5756
  file.path &&
5689
5757
  !seen.has(file.path) &&
5690
5758
  seen.add(file.path)
5691
5759
  )
5692
5760
  .map(file => ({
5693
5761
  path: file.path,
5694
- content: file.content
5762
+ content: file.content,
5763
+ kind: 'text',
5764
+ source: file.source,
5765
+ ...(file.documentIdentity !== undefined
5766
+ ? { documentIdentity: snapshotDocumentIdentity(file.documentIdentity, file.path, project.id) } : {})
5695
5767
  }));
5696
5768
  }
5697
5769
 
5770
+ function snapshotDocumentIdentity(identity, path, projectId) {
5771
+ // Preserve invalid evidence as invalid, never silently downgrade to the
5772
+ // missing-evidence fallback. Native independently hashes the content.
5773
+ if (!identity || identity.version !== 1 || identity.source !== 'cm-ranges-v1'
5774
+ || identity.path !== path || identity.projectId !== projectId
5775
+ || typeof identity.documentId !== 'string' || !/^[a-f0-9]{24}$/.test(identity.documentId)
5776
+ || typeof identity.targetDocumentId !== 'string' || !/^[a-f0-9]{24}$/.test(identity.targetDocumentId)
5777
+ || typeof identity.contentSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(identity.contentSha256)) return null;
5778
+ return { version: 1, source: 'cm-ranges-v1', projectId, path,
5779
+ documentId: identity.documentId, targetDocumentId: identity.targetDocumentId, contentSha256: identity.contentSha256 };
5780
+ }
5781
+
5698
5782
  function normalizeSnapshotPath(path) {
5699
5783
  // Snapshot/overlay/focus arrays already carry canonical names from file chips.
5700
5784
  return Modules.ProjectFiles.normalizeSafeProjectPath(path);
@@ -5962,6 +6046,8 @@
5962
6046
  if (!accountScopeId || isScopedPersistenceIdentityFailure(error)) {
5963
6047
  throw error;
5964
6048
  }
6049
+ const fallbackProjectId = getCurrentProjectId();
6050
+ const fallbackGeneration = spaRouteGeneration;
5965
6051
  const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5966
6052
  const stored = await chrome.storage.local.get([fallbackKey]);
5967
6053
  const fallback = stored[fallbackKey] || {};
@@ -5969,6 +6055,10 @@
5969
6055
  fallback?.__codexOverleafCompactFallback === true
5970
6056
  && fallback.__codexOverleafFallbackAccountScopeId === accountScopeId
5971
6057
  ) {
6058
+ if (accountScopeId === cachedAccountScopeId && fallbackProjectId === getCurrentProjectId()
6059
+ && fallbackGeneration === spaRouteGeneration) {
6060
+ Modules.StorageMigration.adoptProjectPreferenceBaseline(fallback, accountScopeId, fallbackProjectId);
6061
+ }
5972
6062
  return {
5973
6063
  mode: fallback.mode || '', providerId: fallback.providerId || 'builtin',
5974
6064
  model: fallback.model || '',
@@ -6050,9 +6140,16 @@
6050
6140
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6051
6141
  compactState.reviewingAvailabilityByProject = state.reviewingAvailabilityByProject;
6052
6142
  compactState.governanceRulesByProject = state.governanceRulesByProject;
6143
+ const projectPreferenceBaseline = Migration.getProjectPreferenceBaseline(
6144
+ options?.draftScope?.accountScopeId || cachedAccountScopeId, projectId);
6053
6145
  const stateSnapshot = typeof structuredClone === 'function'
6054
6146
  ? structuredClone(state)
6055
6147
  : JSON.parse(JSON.stringify(state));
6148
+ const projectPreferenceWrite = Migration.captureProjectPreferenceWrite({
6149
+ state: stateSnapshot, baseline: projectPreferenceBaseline,
6150
+ accountScopeId: options?.draftScope?.accountScopeId || cachedAccountScopeId, projectId,
6151
+ normalizeExperimentalOtByProject, normalizeGovernanceRulesByProject, normalizeCustomInstructionsByProject
6152
+ });
6056
6153
  // Keep consumption provenance on the live run owner across ordinary
6057
6154
  // session normalization; it is not a composer edit or a queued input.
6058
6155
  const consumedSession = liveRunView?.draftConsumption && liveRunView.runProjectId === projectId
@@ -6077,6 +6174,28 @@
6077
6174
  normalizeExperimentalOtByProject,
6078
6175
  normalizeGovernanceRulesByProject,
6079
6176
  normalizeCustomInstructionsByProject,
6177
+ projectPreferenceBaseline,
6178
+ projectPreferenceWrite,
6179
+ applyProjectPreferences: (prefs, captured, fields) => {
6180
+ if (retentionScope.accountScopeId !== cachedAccountScopeId || retentionScope.projectId !== getCurrentProjectId()
6181
+ || retentionScope.generation !== spaRouteGeneration) return false;
6182
+ const adopted = [];
6183
+ for (const field of fields) {
6184
+ if (JSON.stringify(state[field]?.[projectId]) !== JSON.stringify(captured[field]?.[projectId])) continue;
6185
+ if (JSON.stringify(state[field]?.[projectId]) === JSON.stringify(prefs[field]?.[projectId])) continue;
6186
+ state[field] = { ...state[field], ...prefs[field] };
6187
+ if (!Object.hasOwn(prefs[field] || {}, projectId)) delete state[field][projectId];
6188
+ adopted.push(field);
6189
+ }
6190
+ if (adopted.includes('customInstructionsByProject')) syncCustomInstructionsEditorForProject(projectId);
6191
+ if (adopted.includes('governanceRulesByProject')) syncProjectSettingsEditorForProject();
6192
+ if (adopted.includes('experimentalOtByProject')) syncExperimentalOtToggleForProject(projectId);
6193
+ if (adopted.includes('reviewingAvailabilityByProject')) {
6194
+ syncReviewingTooltip();
6195
+ syncProbeStatus();
6196
+ }
6197
+ return true;
6198
+ },
6080
6199
  persistenceContext
6081
6200
  });
6082
6201
  if (!options?.draftScope) {
@@ -6534,9 +6653,17 @@
6534
6653
  if (!composerDraftPersistence) composerDraftPersistence = Modules.ComposerAttachmentDraft.createController({
6535
6654
  getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() }),
6536
6655
  getAttachments: () => composerAttachmentController.serializePersistableAttachments(),
6537
- setAttachments: value => { state.composerAttachments = value; },
6656
+ getOmittedAttachmentNames: () => composerAttachmentController.getOmittedAttachmentNames(),
6657
+ setAttachments: value => {
6658
+ state.composerAttachments = value;
6659
+ syncComposerSendAvailability();
6660
+ },
6538
6661
  clear: () => composerAttachmentController.clear({ notify: false }),
6539
6662
  restore: value => composerAttachmentController.restorePersistedAttachments(value),
6663
+ onRestoreOmittedAttachments: ({ count, names }) => appendPlainLog(tx(
6664
+ `${count} attachment${count === 1 ? '' : 's'} could not be saved in the draft. Reattach before sending: ${names.join(', ')}.`,
6665
+ `${count} 个附件未能保存到草稿中,请在发送前重新添加:${names.join('、')}。`
6666
+ )),
6540
6667
  read: scope => Modules.StorageDb.readComposerAttachmentDraft(scope),
6541
6668
  write: (scope, value) => Modules.StorageDb.writeComposerAttachmentDraft(scope, value),
6542
6669
  scheduleSave: () => saveStateSoon()
@@ -6638,7 +6765,9 @@
6638
6765
  writingStyleSettings.sync();
6639
6766
  applyPanelTheme(getThemePreference());
6640
6767
  if ((state.providerId || 'builtin') === 'builtin' && !modelSelectHasOption(state.model)) {
6641
- renderModelOptions(getModelCatalog().FALLBACK_MODELS, state.model);
6768
+ renderModelOptions(getModelCatalog().FALLBACK_MODELS, state.model, {
6769
+ provisionalSelected: getModelDiscovery().provisional === true
6770
+ });
6642
6771
  }
6643
6772
  panel.querySelector('[data-model]').value = state.model;
6644
6773
  const renderedModels = getRenderedModelEntries();
@@ -7614,13 +7743,16 @@
7614
7743
  const originalStatus = record.status;
7615
7744
  const retryAutoRecompile = typeof record.executionSnapshot?.autoRecompile === 'boolean'
7616
7745
  ? record.executionSnapshot.autoRecompile : undefined;
7746
+ const generation = spaRouteGeneration;
7747
+ const isOwnerScope = () => generation === spaRouteGeneration
7748
+ && owner.projectId === getCurrentProjectId() && owner.accountScopeId === cachedAccountScopeId;
7617
7749
  // State is renormalized during the awaits below (probes, session sync), which
7618
7750
  // replaces run objects; always write through the current copy.
7619
- const live = () => findRunRecord(runId, session.id) || record;
7751
+ const live = () => (isOwnerScope() && findRunRecord(runId, session.id)) || record;
7620
7752
  let finished = false;
7753
+ let retryOutcome = null;
7621
7754
  runCancellationRequested = false;
7622
7755
  runCancellationController = new AbortController();
7623
- const generation = spaRouteGeneration;
7624
7756
  const requestId = record.nativeRequestId || `writeback:${owner.sessionId}:${runId}`;
7625
7757
  const ownerToken = crypto.randomUUID();
7626
7758
  const retryView = { recordId: runId, sessionId: session.id, runProjectId: owner.projectId,
@@ -7632,7 +7764,7 @@
7632
7764
  if (runCancellationRequested || currentRunView !== retryView) {
7633
7765
  throw Object.assign(new Error('Save checking was cancelled.'), { code: 'codex_cancelled' });
7634
7766
  }
7635
- if (generation !== spaRouteGeneration || owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId) {
7767
+ if (!isOwnerScope()) {
7636
7768
  throw Object.assign(new Error('The project or account changed.'), { code: 'aborted_project_changed' });
7637
7769
  }
7638
7770
  };
@@ -7649,6 +7781,9 @@
7649
7781
  Object.assign(retryView, { root, events: root?.querySelector('[data-run-events]'),
7650
7782
  report: root?.querySelector('[data-run-report]'), status: root?.querySelector('[data-run-status]'),
7651
7783
  runProcess: root?.querySelector('[data-run-process]'), processLabel: root?.querySelector('[data-run-process-summary]') });
7784
+ const pendingMirror = getPendingMirrorRefresh?.();
7785
+ if (pendingMirror) await awaitRunStep(pendingMirror.catch(() => {}));
7786
+ assertOwner();
7652
7787
  if (intent?.requestId) {
7653
7788
  const receipt = await callPageBridge('getWritebackReceipt', { requestId: intent.requestId, runProjectId: owner.projectId });
7654
7789
  if (receipt?.state === 'running') throw new Error(tx('The previous upload is still finishing. Try again shortly.', '上一轮上传仍在收尾,请稍后再试。'));
@@ -7662,6 +7797,7 @@
7662
7797
  ? record.executionSnapshot.requireReviewing : intent.requireReviewing,
7663
7798
  autoRecompile: retryAutoRecompile,
7664
7799
  retryCreates: true, assistantMessageKey: 'retry_sync', assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7800
+ retryOutcome = result;
7665
7801
  assertOwner();
7666
7802
  if (result.settlement?.facts) live().settlement = WritebackSettlement.mergeRetrySettlement(live().settlement, result.settlement.facts); // Keep files outside the retry.
7667
7803
  await finishRunView(result.hasSkippedOperations ? tx('Sync incomplete', '同步未完成') : tx('Synced', '已同步'),
@@ -7682,6 +7818,19 @@
7682
7818
  }
7683
7819
  await flushQueuedSaveState();
7684
7820
  } catch (error) {
7821
+ const outcome = error.writebackOutcome || retryOutcome;
7822
+ const current = findRunRecord(runId, session.id);
7823
+ if (outcome && (runCancellationRequested || isRunCancellationError(error))
7824
+ && currentRunView === retryView && isOwnerScope()
7825
+ && current && !current.forkSnapshot && current.runProjectId === owner.projectId) {
7826
+ // Stop cannot erase writes returned by Retry sync, including a saved
7827
+ // reply that arrived after cancellation. Keep earlier files' facts too.
7828
+ if (outcome.settlement?.facts) current.settlement = WritebackSettlement.mergeRetrySettlement(
7829
+ current.settlement, outcome.settlement.facts);
7830
+ appendRunCancelledReport(outcome, false);
7831
+ await finishRunView(tx('Cancelled', '已取消'), 'cancelled');
7832
+ finished = true;
7833
+ }
7685
7834
  showPluginToast(error.message || String(error));
7686
7835
  } finally {
7687
7836
  // Only finishRunView settles a new terminal status; every other exit