codex-overleaf-link 2.2.1 → 2.3.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 (79) hide show
  1. package/README.md +25 -23
  2. package/extension/bootstrap/manifest.template.json +1 -1
  3. package/extension/runtime-manifest.json +5 -71
  4. package/extension/src/background.js +67 -63
  5. package/extension/src/backgroundUpdateCoordinator.js +128 -66
  6. package/extension/src/content/applyResultFormatters.js +2 -3
  7. package/extension/src/content/composerPanel.js +0 -2
  8. package/extension/src/content/contentRuntime.js +757 -1558
  9. package/extension/src/content/contextTray.js +4 -6
  10. package/extension/src/content/diagnosticsController.js +1 -2
  11. package/extension/src/content/diffReviewPanel.js +14 -13
  12. package/extension/src/content/generated/content.bundle.js +41605 -0
  13. package/extension/src/content/generated/content.bundle.meta.json +103 -0
  14. package/extension/src/content/legacyGlobalRegistry.js +129 -0
  15. package/extension/src/content/markdownText.js +17 -17
  16. package/extension/src/content/mathText.js +155 -9
  17. package/extension/src/content/modelPicker.js +1 -1
  18. package/extension/src/content/modelPickerSupport.js +10 -5
  19. package/extension/src/content/moduleRegistryKernel.js +125 -0
  20. package/extension/src/content/nativeCompatibilityController.js +313 -0
  21. package/extension/src/content/otWarmMirror.js +8 -6
  22. package/extension/src/content/pageBridgeClient.js +224 -0
  23. package/extension/src/content/panelMaintenance.js +8 -3
  24. package/extension/src/content/postNavigationSettlementPersistence.js +100 -0
  25. package/extension/src/content/projectProviderSelection.js +4 -12
  26. package/extension/src/content/projectSessionCleanup.js +3 -3
  27. package/extension/src/content/providerSettingsCoordinator.js +59 -26
  28. package/extension/src/content/providerSettingsDialog.js +30 -18
  29. package/extension/src/content/recentProjects.js +15 -23
  30. package/extension/src/content/runGuidanceController.js +1 -1
  31. package/extension/src/content/runQueueScheduler.js +129 -29
  32. package/extension/src/content/runSettlementPersistence.js +51 -0
  33. package/extension/src/content/runTimelineView.js +17 -8
  34. package/extension/src/content/scopedPersistenceCoordinator.js +100 -0
  35. package/extension/src/content/scopedPersistenceDetachedCommit.js +47 -0
  36. package/extension/src/content/scopedPersistencePanelState.js +111 -0
  37. package/extension/src/content/sessionManager.js +15 -12
  38. package/extension/src/content/sessionPersistence.js +67 -8
  39. package/extension/src/content/updateNotice.js +10 -4
  40. package/extension/src/content/writebackOrchestrator.js +89 -68
  41. package/extension/src/contentScript.js +5 -1
  42. package/extension/src/page/trackedChangesLifecycle.js +16 -5
  43. package/extension/src/page/writebackRouter.js +6 -2
  44. package/extension/src/pageBridge.js +47 -98
  45. package/extension/src/shared/agentTranscript.js +18 -8
  46. package/extension/src/shared/compatibility.js +1 -1
  47. package/extension/src/shared/governanceRules.js +1 -1
  48. package/extension/src/shared/i18n.js +2 -4
  49. package/extension/src/shared/managedUpdateProjection.js +337 -0
  50. package/extension/src/shared/nativeRequestIdentity.js +60 -0
  51. package/extension/src/shared/otText.js +0 -19
  52. package/extension/src/shared/pageRpcContract.js +136 -0
  53. package/extension/src/shared/runExecutionSnapshot.js +187 -0
  54. package/extension/src/shared/runExecutionSnapshotCodec.js +171 -0
  55. package/extension/src/shared/runInputQueue.js +81 -34
  56. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +79 -0
  57. package/extension/src/shared/scopedPersistenceQueuePolicy.js +200 -0
  58. package/extension/src/shared/scopedPersistenceReceipt.js +60 -0
  59. package/extension/src/shared/scopedPersistenceTransaction.js +241 -0
  60. package/extension/src/shared/sessionState.js +110 -59
  61. package/extension/src/shared/settlementFacts.js +351 -0
  62. package/extension/src/shared/storageDb.js +25 -10
  63. package/extension/src/shared/storageMigration.js +275 -61
  64. package/extension/src/shared/storageSessionClaims.js +60 -0
  65. package/extension/src/shared/updateConsent.js +4 -0
  66. package/extension/src/shared/updateRevocationIntent.js +109 -0
  67. package/extension/src/shared/writebackEvidenceProjection.js +150 -0
  68. package/extension/src/shared/writebackOperationEvidence.js +153 -0
  69. package/extension/src/shared/writebackSettlement.js +727 -0
  70. package/extension/styles/panel.css +18 -8
  71. package/native-host/src/codexPromptAssembly.js +1 -1
  72. package/native-host/src/codexRuntimeIdentity.js +1 -1
  73. package/native-host/src/codexSessionRunner.js +7 -13
  74. package/native-host/src/index.js +3 -69
  75. package/native-host/src/managedInstall.js +50 -8
  76. package/native-host/src/nativeTransportEnvelope.js +148 -0
  77. package/native-host/src/taskRunnerRuntime.js +14 -84
  78. package/native-host/src/updateManager.js +36 -1
  79. package/package.json +13 -5
@@ -1,8 +1,16 @@
1
1
  (function initCodexOverleafContentRuntime(root) {
2
2
  'use strict';
3
- function init() {
3
+ function init(dependencies) {
4
4
  'use strict';
5
5
 
6
+ dependencies = dependencies || {};
7
+ const Modules = dependencies.modules;
8
+ if (!Modules || typeof Modules !== 'object') {
9
+ throw Object.assign(new Error('Codex Overleaf content runtime requires validated dependencies.'), {
10
+ code: 'content_dependency_registry_required'
11
+ });
12
+ }
13
+
6
14
  const RUNTIME_INSTALLED_FLAG = '__codexOverleafContentRuntimeInstalled';
7
15
  const RUNTIME_STATE_KEY = '__codexOverleafContentRuntimeState';
8
16
  if (root[RUNTIME_INSTALLED_FLAG]) {
@@ -24,9 +32,6 @@
24
32
  const LEGACY_STORAGE_KEY = 'codexOverleafPanelState';
25
33
  const RUN_PROJECT_SYNC_MAX_AGE_MS = 30000;
26
34
  const RUN_SNAPSHOT_ZIP_TIMEOUT_MS = 30000;
27
- const SNAPSHOT_PAGE_BRIDGE_TIMEOUT_MS = 70000;
28
- const COMPILE_PAGE_BRIDGE_TIMEOUT_MS = 75000;
29
- const CONTEXT_FILE_LIST_PAGE_BRIDGE_TIMEOUT_MS = 35000;
30
35
  const STREAM_RENDER_FLUSH_MS = 80;
31
36
  const STREAM_SAVE_DELAY_MS = 1000;
32
37
  const MAX_RUN_EVENTS = 300;
@@ -45,7 +50,6 @@
45
50
  'codex.providers.clear-secret',
46
51
  'codex.providers.delete',
47
52
  'task.run',
48
- 'task.confirm',
49
53
  'mirror.sync',
50
54
  'mirror.patchFiles',
51
55
  'mirror.confirmWriteback',
@@ -55,31 +59,28 @@
55
59
  'skills.install',
56
60
  'skills.remove'
57
61
  ]);
58
- const CANCELLABLE_PAGE_BRIDGE_METHODS = new Set([
59
- 'getProjectSnapshot',
60
- 'getProjectFileList',
61
- 'getCompileLog',
62
- 'ensureReviewing',
63
- 'ensureEditing',
64
- 'waitForSaveState',
65
- 'applyOperations',
66
- 'acceptTrackedChanges',
67
- 'rejectTrackedChanges'
68
- ]);
69
- const activePageBridgeCancellationHandlers = new Map();
62
+ const PageRpcContract = Modules.PageRpcContract;
70
63
  const PANEL_DEFAULT_WIDTH = 380;
71
64
  const PANEL_MIN_WIDTH = 340;
72
65
  const PANEL_MAX_WIDTH = 760;
73
66
  const PAGE_MIN_WIDTH = 520;
74
- const CodexOverleafCompatibility = window.CodexOverleafCompatibility;
67
+ const CodexOverleafCompatibility = Modules.Compatibility;
75
68
  const INSTALL_COMMAND = CodexOverleafCompatibility?.buildInstallCommand?.(
76
69
  undefined,
77
70
  undefined,
78
71
  getCurrentExtensionId()
79
72
  ) || 'curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/main/install.sh | bash -s -- --extension-id <chrome-extension-id>';
80
- const PAGE_BRIDGE_SCRIPT_REVISION = '2026-05-21-editor-readiness-v11';
81
- const PAGE_BRIDGE_CAPABILITY = createPageBridgeCapability();
82
- const pageBridgeReady = injectPageBridge();
73
+ const pageBridgeClient = Modules.PageBridgeClient.create({
74
+ root,
75
+ window,
76
+ document,
77
+ chromeApi: chrome,
78
+ crypto,
79
+ contract: PageRpcContract,
80
+ compatibility: CodexOverleafCompatibility,
81
+ isCancellationRequested: () => runCancellationRequested
82
+ });
83
+ pageBridgeClient.start();
83
84
  const {
84
85
  createSession,
85
86
  deleteSession,
@@ -92,43 +93,45 @@
92
93
  selectVisibleSessionsForList,
93
94
  setActiveSession,
94
95
  updateActiveSession
95
- } = window.CodexOverleafSessionState;
96
- const LineReferences = window.CodexOverleafLineReferences;
97
- const { getProjectStorageKey } = window.CodexOverleafStorageKeys;
98
- const { HIGH_RISK_TYPES, buildChangeSummaryLine, hasSkippedApplyOperations } = window.CodexOverleafSummary;
96
+ } = Modules.SessionState;
97
+ const LineReferences = Modules.LineReferences;
98
+ const { getProjectStorageKey } = Modules.StorageKeys;
99
+ const { HIGH_RISK_TYPES, buildChangeSummaryLine, hasSkippedApplyOperations } = Modules.Summary;
99
100
  const {
100
101
  buildExpectedFilesAfterOperations,
101
102
  buildSnapshotRestoreUndo,
102
103
  buildUndoCheckpoint
103
- } = window.CodexOverleafUndoOperations;
104
+ } = Modules.UndoOperations;
104
105
  const { buildHumanCompletionReport, mapAgentEventToActivity, stripEmptyHtmlCommentPlaceholders,
105
- translateRawError } = window.CodexOverleafAgentTranscript;
106
- const i18n = window.CodexOverleafI18n;
107
- const nativeChannel = window.CodexOverleafNativeChannel.create({
106
+ translateRawError } = Modules.AgentTranscript;
107
+ const i18n = Modules.I18n;
108
+ const nativeChannel = Modules.NativeChannel.create({
108
109
  chrome,
109
110
  crypto
110
111
  });
111
- const writebackController = window.CodexOverleafWritebackController;
112
- const ReviewHunks = window.CodexOverleafReviewHunks;
113
- const DiffReviewPanel = window.CodexOverleafDiffReviewPanel;
114
- const ContextTray = window.CodexOverleafContextTray;
115
- const LocalSkillsPanel = window.CodexOverleafLocalSkillsPanel;
116
- const runController = window.CodexOverleafRunController;
117
- const RunInputQueue = window.CodexOverleafRunInputQueue;
118
- const RunQueueScheduler = window.CodexOverleafRunQueueScheduler;
119
- const ActiveTurnControl = window.CodexOverleafActiveTurnControl;
120
- const PendingInputView = window.CodexOverleafPendingInputView;
121
- const mirrorHealth = window.CodexOverleafMirrorHealth;
122
- const otWarmMirrorController = window.CodexOverleafOtWarmMirrorController;
123
- const PanelRenderer = window.CodexOverleafPanelRenderer;
124
- const SessionPanel = window.CodexOverleafSessionPanel;
125
- const SettingsPanel = window.CodexOverleafSettingsPanel;
126
- const DiagnosticsPanel = window.CodexOverleafDiagnosticsPanel;
127
- const ComposerPanel = window.CodexOverleafComposerPanel;
128
- const GovernanceRules = window.CodexOverleafGovernanceRules;
129
- const SensitiveScan = window.CodexOverleafSensitiveScan;
130
- const AuditRecords = window.CodexOverleafAuditRecords;
131
- const FailureReasons = window.CodexOverleafFailureReasons;
112
+ const writebackController = Modules.WritebackController;
113
+ const ReviewHunks = Modules.ReviewHunks;
114
+ const DiffReviewPanel = Modules.DiffReviewPanel;
115
+ const ContextTray = Modules.ContextTray;
116
+ const LocalSkillsPanel = Modules.LocalSkillsPanel;
117
+ const runController = Modules.RunController;
118
+ const RunInputQueue = Modules.RunInputQueue;
119
+ const RunQueueScheduler = Modules.RunQueueScheduler;
120
+ const ActiveTurnControl = Modules.ActiveTurnControl;
121
+ const PendingInputView = Modules.PendingInputView;
122
+ const mirrorHealth = Modules.MirrorHealth;
123
+ const otWarmMirrorController = Modules.OtWarmMirrorController;
124
+ const PanelRenderer = Modules.PanelRenderer;
125
+ const SessionPanel = Modules.SessionPanel;
126
+ const SettingsPanel = Modules.SettingsPanel;
127
+ const DiagnosticsPanel = Modules.DiagnosticsPanel;
128
+ const ComposerPanel = Modules.ComposerPanel;
129
+ const GovernanceRules = Modules.GovernanceRules;
130
+ const SensitiveScan = Modules.SensitiveScan;
131
+ const AuditRecords = Modules.AuditRecords;
132
+ const FailureReasons = Modules.FailureReasons;
133
+ const RunExecutionSnapshot = Modules.RunExecutionSnapshot;
134
+ const WritebackSettlement = Modules.WritebackSettlement;
132
135
 
133
136
  // Module composition (hoisted-safe zone). These wiring blocks MUST run
134
137
  // before any controller below consumes their destructured exports by
@@ -140,7 +143,6 @@
140
143
  // `rejected`; `accepted` and `rejected` are terminal. Kept in sync with
141
144
  // sessionState.js. A run is in the tracked-change lifecycle when it carries
142
145
  // one of these values or still holds tracked-change refs.
143
- const TERMINAL_TRACKED_CHANGE_STATUS = new Set(['accepted', 'rejected']);
144
146
 
145
147
  // The UI-local in-flight lock for tracked-change accept/reject. Never written
146
148
  // to trackedChangeStatus and never persisted — it lives only on the controller.
@@ -173,10 +175,14 @@
173
175
  // runtime collaborators they need; the destructured consts keep every
174
176
  // existing call site unchanged. Function declarations hoist, so passing them
175
177
  // here is safe; mutable state is handed over as lazy getters.
176
- const markdownText = window.CodexOverleafMarkdownText.create({
178
+ const markdownText = Modules.MarkdownText.create({
179
+ LineReferences: Modules.LineReferences,
180
+ MathText: Modules.MathText,
177
181
  tx,
178
182
  callPageBridge,
183
+ getCurrentProjectId,
179
184
  getCurrentProjectReferenceFiles,
185
+ getCurrentProjectMathSources,
180
186
  showPluginToast,
181
187
  recordRenderingDiagnostic: detail => appendTechnicalEvent({ type: 'rendering.diagnostic', detail })
182
188
  });
@@ -190,8 +196,8 @@
190
196
  hasUnsafeRuntimePathSegments
191
197
  } = markdownText;
192
198
 
193
- const projectSettingsCoordinator = window.CodexOverleafProjectSettingsCoordinator.create({
194
- CodexOverleafTheme: window.CodexOverleafTheme,
199
+ const projectSettingsCoordinator = Modules.ProjectSettingsCoordinator.create({
200
+ CodexOverleafTheme: Modules.Theme,
195
201
  GovernanceRules,
196
202
  SettingsPanel,
197
203
  closeContextTray: () => closeContextTray(),
@@ -248,7 +254,11 @@
248
254
  updateSkillsEntrySummary
249
255
  } = projectSettingsCoordinator;
250
256
 
251
- const otWarmMirror = window.CodexOverleafOtWarmMirror.create({
257
+ const otWarmMirror = Modules.OtWarmMirror.create({
258
+ otWarmMirrorController,
259
+ runController,
260
+ mirrorHealth,
261
+ projectFiles: Modules.ProjectFiles,
252
262
  tr,
253
263
  tx,
254
264
  closeDiagnosticsMenu,
@@ -308,7 +318,8 @@
308
318
  prepareMirrorStaleRetry
309
319
  } = otWarmMirror;
310
320
 
311
- const diagnosticsController = window.CodexOverleafDiagnosticsController.create({
321
+ const diagnosticsController = Modules.DiagnosticsController.create({
322
+ Compatibility: Modules.Compatibility,
312
323
  tr,
313
324
  tx,
314
325
  getExtensionCompatibilityMetadata,
@@ -352,7 +363,8 @@
352
363
  // recovery-action handlers, change-history read path, history & storage
353
364
  // card, aggressive-compaction notice. Instantiated BEFORE runTimelineView,
354
365
  // whose deps reference the recovery handlers.
355
- const panelMaintenance = window.CodexOverleafPanelMaintenance.create({
366
+ const panelMaintenance = Modules.PanelMaintenance.create({
367
+ StorageDb: Modules.StorageDb,
356
368
  tx,
357
369
  showPluginToast: (...args) => showPluginToast(...args),
358
370
  showPluginConfirm: (...args) => showPluginConfirm(...args),
@@ -386,7 +398,8 @@
386
398
  openStorageSettings
387
399
  } = panelMaintenance;
388
400
 
389
- const runTimelineView = window.CodexOverleafRunTimelineView.create({
401
+ const runTimelineView = Modules.RunTimelineView.create({
402
+ RunGuidanceView: Modules.RunGuidanceView,
390
403
  tr,
391
404
  tx,
392
405
  getLocale,
@@ -413,7 +426,8 @@
413
426
  showNativeSetupGuidance: () => showNativeUpdateGuidanceModal({}),
414
427
  openProjectFileForFailure,
415
428
  openStorageSettings,
416
- trackedChangeInFlight
429
+ trackedChangeInFlight,
430
+ projectRunSettlement: run => WritebackSettlement.projectRunSettlement(run)
417
431
  });
418
432
  const {
419
433
  resetAutoFollow,
@@ -435,7 +449,10 @@
435
449
  configureAcceptButton
436
450
  } = runTimelineView;
437
451
 
438
- const sessionManager = window.CodexOverleafSessionManager.create({
452
+ const sessionManager = Modules.SessionManager.create({
453
+ SessionPanel: Modules.SessionPanel,
454
+ SessionState: Modules.SessionState,
455
+ SessionMenuView: Modules.SessionMenuView,
439
456
  tr,
440
457
  showPluginConfirm,
441
458
  showPluginToast,
@@ -466,7 +483,9 @@
466
483
  updateSessionById
467
484
  } = sessionManager;
468
485
 
469
- const applyResultFormatters = window.CodexOverleafApplyResultFormatters.create({
486
+ const applyResultFormatters = Modules.ApplyResultFormatters.create({
487
+ i18n: Modules.I18n,
488
+ FailureReasons: Modules.FailureReasons,
470
489
  getLocale,
471
490
  tr,
472
491
  tx,
@@ -484,12 +503,20 @@
484
503
  formatOperationFiles
485
504
  } = applyResultFormatters;
486
505
 
506
+ const runSettlementPersistence = Modules.RunSettlementPersistence.create({
507
+ writebackSettlement: WritebackSettlement,
508
+ getCurrentRunView: () => currentRunView,
509
+ findRunRecord,
510
+ getCurrentProjectId,
511
+ saveStateSoon
512
+ });
513
+
487
514
  // v1.6.3 structural-debt phase 6: the sync-writeback orchestration
488
515
  // (applySyncChangesToOverleaf + post-write verify/mirror/compile pipeline)
489
516
  // lives in writebackOrchestrator.js. Every dep below is either a hoisted
490
517
  // function declaration, an already-initialized const above, or a lazy
491
518
  // accessor thunk — safe in the top-of-init() wiring zone.
492
- const writebackOrchestrator = window.CodexOverleafWritebackOrchestrator.create({
519
+ const writebackOrchestrator = Modules.WritebackOrchestrator.create({
493
520
  tr,
494
521
  tx,
495
522
  getLocale,
@@ -499,7 +526,6 @@
499
526
  appendOperationsPreview,
500
527
  appendPartialWritebackWarning,
501
528
  appendApplyResult,
502
- renderDiffReview,
503
529
  renderReadOnlyDiffReview,
504
530
  showPluginConfirm,
505
531
  callPageBridge,
@@ -529,9 +555,12 @@
529
555
  confirmBinaryOperations,
530
556
  filterSyncChangesByOperations,
531
557
  writebackController,
558
+ writebackSettlement: WritebackSettlement,
559
+ compileAdapter: Modules.CompileAdapter,
532
560
  RUN_SNAPSHOT_ZIP_TIMEOUT_MS,
533
561
  getState: () => state,
534
- getCurrentRunView: () => currentRunView
562
+ getCurrentRunView: () => currentRunView,
563
+ onMirrorRefreshSettled: runSettlementPersistence.settleMirrorRefresh
535
564
  });
536
565
  const {
537
566
  applySyncChangesToOverleaf,
@@ -570,7 +599,8 @@
570
599
  }
571
600
  }
572
601
 
573
- const modelPicker = window.CodexOverleafModelPicker.create({
602
+ const modelPicker = Modules.ModelPicker.create({
603
+ Support: Modules.ModelPickerSupport,
574
604
  tr,
575
605
  tx,
576
606
  getLocale,
@@ -603,7 +633,7 @@
603
633
  updateModelDisplay
604
634
  } = modelPicker;
605
635
 
606
- const projectProviderSelection = window.CodexOverleafProjectProviderSelection.create({
636
+ const projectProviderSelection = Modules.ProjectProviderSelection.create({
607
637
  tx, tr, getRunningSessionIds, showToast: showPluginToast, showConfirm: showPluginConfirm,
608
638
  getState: () => state, setState: next => { state = next; }, getPanel: () => panel,
609
639
  readSelectedModel: readSelectedModelInput, readSelectedSpeed: readSelectedSpeedInput,
@@ -611,7 +641,9 @@
611
641
  normalizeState: normalizePanelState, applyStateToPanel
612
642
  });
613
643
 
614
- const providerSettingsCoordinator = window.CodexOverleafProviderSettingsCoordinator.create({
644
+ const providerSettingsCoordinator = Modules.ProviderSettingsCoordinator.create({
645
+ ProviderProfiles: Modules.ProviderProfiles,
646
+ ProviderSettingsDialog: Modules.ProviderSettingsDialog,
615
647
  tx,
616
648
  sendBackgroundNative,
617
649
  document,
@@ -658,7 +690,13 @@
658
690
  }
659
691
  });
660
692
 
661
- const recentProjects = window.CodexOverleafRecentProjects.create({
693
+ const recentProjects = Modules.RecentProjects.create({
694
+ ProjectSessionCleanup: Modules.ProjectSessionCleanup,
695
+ SessionPersistence: Modules.SessionPersistence,
696
+ SessionState: Modules.SessionState,
697
+ StorageDb: Modules.StorageDb,
698
+ StorageKeys: Modules.StorageKeys,
699
+ StorageMigration: Modules.StorageMigration,
662
700
  tr,
663
701
  tx,
664
702
  openCustomInstructionsSettings,
@@ -690,7 +728,7 @@
690
728
  const MAX_COMPOSER_ATTACHMENT_TOTAL_BYTES = 32 * 1024 * 1024;
691
729
  const MAX_COMPOSER_ATTACHMENTS = 8;
692
730
  const MAX_ATTACHMENT_PREVIEW_DATA_URL_CHARS = 768 * 1024;
693
- const composerAttachmentController = window.CodexOverleafComposerAttachments.createComposerAttachmentController({
731
+ const composerAttachmentController = Modules.ComposerAttachments.createComposerAttachmentController({
694
732
  getPanel: () => panel,
695
733
  tr,
696
734
  tx,
@@ -708,6 +746,7 @@
708
746
  reviewHunks: ReviewHunks,
709
747
  tr,
710
748
  callPageBridge,
749
+ getCurrentProjectId: () => getCurrentProjectId(),
711
750
  getRunEvents: () => currentRunView?.events,
712
751
  appendRunEvent,
713
752
  scrollLogToBottom,
@@ -736,7 +775,7 @@
736
775
  closeDiagnosticsMenu,
737
776
  closeCustomInstructionsSettings,
738
777
  closeSlashMenu,
739
- projectFiles: window.CodexOverleafProjectFiles
778
+ projectFiles: Modules.ProjectFiles
740
779
  });
741
780
  const localSkillsPanel = LocalSkillsPanel.createLocalSkillsPanelController({
742
781
  root: window,
@@ -795,6 +834,8 @@
795
834
  // (captured at the start of the in-flight save) be the last writer.
796
835
  let saveStateInFlight = false;
797
836
  let saveStateRunAfterFlight = false;
837
+ let saveStateInFlightPromise = null;
838
+ let pendingSaveStateOptions = null;
798
839
  let streamRenderTimer = null;
799
840
  let pendingStreamRenderEvents = new Map();
800
841
  let storageNoticeKeys = new Set();
@@ -806,7 +847,35 @@
806
847
  let runCancellationRequested = false;
807
848
  let runCancellationController = null;
808
849
  let pluginConfirmController = null;
809
- const updateIdleClient = root.CodexOverleafUpdateIdle?.create({
850
+ let scopedPersistenceCoordinator = null;
851
+ const nativeCompatibilityController = Modules.NativeCompatibilityController.create({
852
+ compatibility: CodexOverleafCompatibility,
853
+ nativeChannel,
854
+ gatedMethods: NATIVE_COMPATIBILITY_GATED_METHODS,
855
+ getExtensionCompatibilityMetadata,
856
+ fallbackNativeCompatibility,
857
+ getNativeCompatibilityClassification,
858
+ installCommand: INSTALL_COMMAND,
859
+ throwIfCancellationRequested: throwIfRunCancellationRequested,
860
+ getCurrentRunView: () => currentRunView,
861
+ appendRunEvent,
862
+ showPluginToast,
863
+ getPanel: () => panel,
864
+ ensurePanelOpen,
865
+ getPanelRendererInstance: () => panelRendererInstance,
866
+ setBadge: (...args) => PanelRenderer.setBadge(...args),
867
+ setDiagnosticsHealth,
868
+ getState: () => state,
869
+ localStorage: root.localStorage,
870
+ document,
871
+ navigator,
872
+ chromeApi: chrome,
873
+ onboardingTipStorageKey: ONBOARDING_TIP_STORAGE_KEY,
874
+ setTimeout: (...args) => root.setTimeout(...args),
875
+ tr,
876
+ tx
877
+ });
878
+ const updateIdleClient = Modules.UpdateIdle?.create({
810
879
  document,
811
880
  getBusyState: () => ({
812
881
  run: Boolean(currentRunView),
@@ -878,6 +947,7 @@
878
947
  });
879
948
 
880
949
  async function init() {
950
+ await refreshAccountScopeId();
881
951
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
882
952
  state = normalizePanelState(await loadStoredState(), { restoreRunningRuns: true });
883
953
  initializeRunQueueScheduler();
@@ -890,7 +960,7 @@
890
960
  .catch(() => {});
891
961
  }, 900);
892
962
  ensurePanelOpen();
893
- root.CodexOverleafUpdateNotice?.mount?.(panel?.panelEl || panel, {
963
+ Modules.UpdateNotice?.mount?.(panel?.panelEl || panel, {
894
964
  getLocale: () => state?.locale || 'en'
895
965
  });
896
966
  // v1.8.1: on a non-editor route the panel must NEVER first paint the
@@ -1441,9 +1511,6 @@
1441
1511
  if (mode === 'ask') {
1442
1512
  return 'Ask';
1443
1513
  }
1444
- if (mode === 'confirm') {
1445
- return 'Confirm';
1446
- }
1447
1514
  if (mode === 'auto') {
1448
1515
  return 'Auto';
1449
1516
  }
@@ -1554,7 +1621,7 @@
1554
1621
  }
1555
1622
 
1556
1623
  async function getRecentAuditLogsForCurrentProject(limit = 12) {
1557
- const StorageDb = window.CodexOverleafStorageDb;
1624
+ const StorageDb = Modules.StorageDb;
1558
1625
  if (!StorageDb?.getAllByIndex) {
1559
1626
  return [];
1560
1627
  }
@@ -1673,20 +1740,36 @@
1673
1740
  await pendingMirror.catch(() => {});
1674
1741
  }
1675
1742
  readPanelInputs();
1676
- // Freeze submitted run identity before the panel can change during the run.
1677
- const submittedMode = state.mode;
1678
- const submittedRequireReviewing = state.requireReviewing === true;
1679
- const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1680
- const submittedSkillLoadingSettings = getSkillLoadingSettings();
1681
- const submittedAttachments = getComposerAttachmentsForRun();
1682
- const submittedSkillInvocation = getComposerSkillInvocationForRun();
1683
-
1684
1743
  const queuedInput = options.queuedInput || null;
1685
1744
  const task = String(queuedInput?.text || state.task || '').trim();
1686
1745
  if (!task) {
1687
1746
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1688
1747
  return;
1689
1748
  }
1749
+ const submittedPanelState = {
1750
+ mode: state.mode,
1751
+ providerId: state.providerId,
1752
+ model: state.model,
1753
+ reasoningEffort: state.reasoningEffort,
1754
+ speedTier: state.speedTier,
1755
+ autoRecompile: state.autoRecompile !== false,
1756
+ requireReviewing: state.requireReviewing === true,
1757
+ focusFiles: getActiveFocusFiles()
1758
+ };
1759
+ const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1760
+ const submittedSkillLoadingSettings = getSkillLoadingSettings();
1761
+ const submittedAttachments = getComposerAttachmentsForRun();
1762
+ const submittedSkillInvocation = getComposerSkillInvocationForRun();
1763
+ await providerSettingsCoordinator.ensureLoaded();
1764
+ const executionSnapshot = queuedInput
1765
+ ? RunExecutionSnapshot.resolveForExecution(
1766
+ RunExecutionSnapshot.fromQueuePayload(queuedInput.payload),
1767
+ { catalog: providerSettingsCoordinator.getCatalog() }
1768
+ )
1769
+ : captureCurrentExecutionSnapshot(submittedPanelState);
1770
+ // Freeze submitted run identity before the panel can change during the run.
1771
+ const submittedMode = executionSnapshot.mode;
1772
+ const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1690
1773
 
1691
1774
  runCancellationRequested = false;
1692
1775
  runCancellationController = new AbortController();
@@ -1694,24 +1777,26 @@
1694
1777
  currentRunView = startRunView({
1695
1778
  task,
1696
1779
  mode: submittedMode,
1697
- model: state.model,
1698
- reasoningEffort: state.reasoningEffort,
1699
- speedTier: state.speedTier,
1780
+ model: executionSnapshot.model,
1781
+ reasoningEffort: executionSnapshot.reasoningEffort,
1782
+ speedTier: executionSnapshot.speedTier,
1783
+ executionSnapshot,
1700
1784
  attachments: submittedAttachments,
1701
1785
  skillInvocation: submittedSkillInvocation,
1702
1786
  queueItemId: queuedInput?.id || ''
1703
1787
  });
1704
1788
  const runSessionId = currentRunView.sessionId;
1705
- if (queuedInput?.id) {
1706
- await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1707
- }
1708
1789
  let runAuditDraft = null;
1709
1790
  try {
1791
+ if (queuedInput?.id) {
1792
+ await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
1793
+ }
1710
1794
  runAuditDraft = await awaitRunStep(createAuditDraftForRun({
1711
1795
  task,
1712
1796
  sessionId: runSessionId,
1713
1797
  mode: submittedMode,
1714
- focusFiles: getActiveFocusFiles()
1798
+ focusFiles: executionSnapshot.focusFiles,
1799
+ executionSnapshot
1715
1800
  }));
1716
1801
  announceCrossTabRunStart();
1717
1802
  // Binary attachments belong to this submitted turn. The run record has
@@ -1726,13 +1811,13 @@
1726
1811
  status: 'running',
1727
1812
  detail: {
1728
1813
  [tr('mode')]: formatModeLabel(submittedMode),
1729
- [tx('Model', '模型')]: state.model,
1730
- [tx('Reasoning effort', '推理强度')]: state.reasoningEffort,
1731
- [tx('Speed', '速度')]: state.speedTier,
1814
+ [tx('Model', '模型')]: executionSnapshot.model,
1815
+ [tx('Reasoning effort', '推理强度')]: executionSnapshot.reasoningEffort,
1816
+ [tx('Speed', '速度')]: executionSnapshot.speedTier,
1732
1817
  [tx('Track required', '要求留痕')]: submittedRequireReviewing ? tx('yes', '是') : tx('no', '否'),
1733
1818
  [tx('Skill', '技能')]: submittedSkillInvocation?.title || tr('noneValue'),
1734
1819
  [tx('Attachments', '附件')]: submittedAttachments.map(attachment => attachment.name).join(listSeparator()) || tr('noneValue'),
1735
- '@context': formatContextItems(getActiveFocusFiles())
1820
+ '@context': formatContextItems(executionSnapshot.focusFiles)
1736
1821
  }
1737
1822
  });
1738
1823
  if (submittedSkillInvocation?.id === 'skill-installer') {
@@ -1744,15 +1829,16 @@
1744
1829
  submittedCustomInstructions,
1745
1830
  submittedSkillLoadingSettings,
1746
1831
  submittedAttachments,
1747
- submittedSkillInvocation
1832
+ submittedSkillInvocation,
1833
+ executionSnapshot
1748
1834
  }));
1749
1835
  return;
1750
1836
  }
1751
1837
  appendRunEvent({
1752
1838
  // @context prioritizes these files; complete project runs may still update related files.
1753
1839
  title: tx(
1754
- `This run will prioritize: ${formatContextItems(getActiveFocusFiles())} (persists across turns; clear via +)`,
1755
- `本轮将优先处理:${formatContextItems(getActiveFocusFiles())}(跨轮保留,可在 + 中清除)`
1840
+ `This run will prioritize: ${formatContextItems(executionSnapshot.focusFiles)} (persists across turns; clear via +)`,
1841
+ `本轮将优先处理:${formatContextItems(executionSnapshot.focusFiles)}(跨轮保留,可在 + 中清除)`
1756
1842
  ),
1757
1843
  status: 'completed'
1758
1844
  });
@@ -1770,7 +1856,7 @@
1770
1856
  return;
1771
1857
  }
1772
1858
 
1773
- let focusFiles = getActiveFocusFiles();
1859
+ let focusFiles = [...executionSnapshot.focusFiles];
1774
1860
  const warmStart = await awaitRunStep(resolveWarmRunStart({ focusFiles, mode: submittedMode }));
1775
1861
  let project = warmStart.project || null;
1776
1862
  let useExistingMirror = warmStart.useExistingMirror;
@@ -1787,7 +1873,7 @@
1787
1873
  if (!useExistingMirror) {
1788
1874
  appendLog(formatProjectSnapshotUserLog(project));
1789
1875
  }
1790
- currentRunView.projectFiles = captureProjectReferenceFiles(project);
1876
+ persistCurrentProjectReferenceFiles(project);
1791
1877
  const snapshotWarnings = useExistingMirror
1792
1878
  ? { blocking: [], nonBlocking: [] }
1793
1879
  : getProjectSnapshotWarnings(project);
@@ -1802,7 +1888,7 @@
1802
1888
  project,
1803
1889
  snapshotWarnings,
1804
1890
  focusFiles,
1805
- isUsableProjectFileContent: window.CodexOverleafProjectFiles.isUsableProjectFileContent
1891
+ isUsableProjectFileContent: Modules.ProjectFiles.isUsableProjectFileContent
1806
1892
  });
1807
1893
  let restrictToFocusFiles = runController.shouldRestrictWritebackToFocus({ focusFiles, focusedPartialSnapshot });
1808
1894
  if (snapshotWarnings.blocking.length && !warmMirrorReuse.useExistingMirror && !focusedPartialSnapshot) {
@@ -1834,7 +1920,7 @@
1834
1920
  skippedFiles: [],
1835
1921
  blockedFiles: snapshotWarnings.blocking.map(reason => ({ path: 'project', reason }))
1836
1922
  });
1837
- finishRunView(tx('Blocked: full project was not read', '已阻止:没有读到完整项目'), 'failed');
1923
+ await finishRunView(tx('Blocked: full project was not read', '已阻止:没有读到完整项目'), 'failed');
1838
1924
  return;
1839
1925
  }
1840
1926
 
@@ -1921,7 +2007,7 @@
1921
2007
  reason: finding.detectorId || 'sensitive'
1922
2008
  }))
1923
2009
  });
1924
- finishRunView(tx('Blocked: sensitive content review required', '已阻止:需要处理敏感内容'), 'failed');
2010
+ await finishRunView(tx('Blocked: sensitive content review required', '已阻止:需要处理敏感内容'), 'failed');
1925
2011
  return;
1926
2012
  }
1927
2013
  if (sensitiveFindings.findings.length) {
@@ -1959,7 +2045,7 @@
1959
2045
  return;
1960
2046
  }
1961
2047
  project = staleRetry.project;
1962
- currentRunView.projectFiles = captureProjectReferenceFiles(project);
2048
+ persistCurrentProjectReferenceFiles(project);
1963
2049
  useExistingMirror = false;
1964
2050
  fileOverlays = null;
1965
2051
  otWarmStart = false;
@@ -1986,7 +2072,7 @@
1986
2072
  const userChoice = await showThreadResumeFailedPrompt();
1987
2073
  if (userChoice === 'new') {
1988
2074
  updateSessionById(runSessionId, { codexThreadId: '' });
1989
- const StorageDb = window.CodexOverleafStorageDb;
2075
+ const StorageDb = Modules.StorageDb;
1990
2076
  if (StorageDb) {
1991
2077
  const record = await StorageDb.getRecord('sessions', runSessionId);
1992
2078
  if (record) {
@@ -2014,7 +2100,7 @@
2014
2100
  } else {
2015
2101
  appendRunEvent({ title: tx('Cancelled: user chose not to create a new thread.', '已取消:用户选择不新建线程。'), status: 'rejected' });
2016
2102
  await finalizeAuditRecord(runAuditDraft, { resultStatus: 'rejected' });
2017
- finishRunView(tx('Cancelled', '已取消'), 'rejected');
2103
+ await finishRunView(tx('Cancelled', '已取消'), 'rejected');
2018
2104
  return;
2019
2105
  }
2020
2106
  }
@@ -2022,7 +2108,7 @@
2022
2108
  if (!response.ok) {
2023
2109
  if (runCancellationRequested || isRunCancellationError(response.error)) {
2024
2110
  appendRunCancelledReport();
2025
- finishRunView(tx('Cancelled', '已中断'), 'rejected');
2111
+ await finishRunView(tx('Cancelled', '已中断'), 'rejected');
2026
2112
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2027
2113
  return;
2028
2114
  }
@@ -2109,7 +2195,7 @@
2109
2195
  sensitiveFindings: sensitiveFindings.findings,
2110
2196
  blockedFiles: [{ path: 'codex.run', reason: response.error?.code || response.error?.message || 'native_error' }]
2111
2197
  });
2112
- finishRunView(response.error?.code === 'project_locked'
2198
+ await finishRunView(response.error?.code === 'project_locked'
2113
2199
  ? tx('Codex task already running', 'Codex 任务正在运行')
2114
2200
  : tx('Local Codex error', '本地 Codex 错误'), 'failed');
2115
2201
  return;
@@ -2159,7 +2245,7 @@
2159
2245
  sensitiveFindings: sensitiveFindings.findings,
2160
2246
  blockedFiles: [{ path: 'codex.run', reason: 'codex_no_usable_result' }]
2161
2247
  });
2162
- finishRunView(tx('Local Codex returned nothing usable', '本地 Codex 没有返回可用结果'), 'failed');
2248
+ await finishRunView(tx('Local Codex returned nothing usable', '本地 Codex 没有返回可用结果'), 'failed');
2163
2249
  return;
2164
2250
  }
2165
2251
  const writebackProject = useExistingMirror
@@ -2169,8 +2255,10 @@
2169
2255
  assistantMessage,
2170
2256
  unsupportedChanges: response.result.unsupportedChanges || [],
2171
2257
  mode: submittedMode,
2172
- requireReviewing: submittedRequireReviewing
2258
+ requireReviewing: submittedRequireReviewing,
2259
+ autoRecompile: executionSnapshot.autoRecompile
2173
2260
  });
2261
+ runSettlementPersistence.applyToCurrentRun(syncOutcome.settlement);
2174
2262
  if (runCancellationRequested) {
2175
2263
  throw createRunCancellationError();
2176
2264
  }
@@ -2180,7 +2268,7 @@
2180
2268
  resultStatus: syncOutcome.hasSkippedOperations ? 'completed_with_skips' : 'completed'
2181
2269
  });
2182
2270
 
2183
- finishRunView(
2271
+ await finishRunView(
2184
2272
  syncOutcome.hasSkippedOperations ? tx('Sync completed with skipped items', '同步完成但有跳过项') : tx('Sync completed', '同步完成'),
2185
2273
  syncOutcome.hasSkippedOperations ? 'failed' : 'completed'
2186
2274
  );
@@ -2213,7 +2301,11 @@
2213
2301
  // can catch early-return branches and unrecognized skip codes.
2214
2302
  const postNavigationStatus = settleRunAfterNavigation(finishedRunRecord, syncOutcome);
2215
2303
  if (postNavigationStatus) {
2216
- persistPostNavigationRunStatus(finishedRunRecord, postNavigationStatus);
2304
+ await persistPostNavigationRunStatus(
2305
+ finishedRunRecord,
2306
+ postNavigationStatus,
2307
+ syncOutcome.settlement
2308
+ );
2217
2309
  }
2218
2310
  }
2219
2311
  } catch (_settlementError) {
@@ -2242,7 +2334,7 @@
2242
2334
  const returnedThreadId = response.result?.threadId || '';
2243
2335
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2244
2336
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2245
- const StorageDb = window.CodexOverleafStorageDb;
2337
+ const StorageDb = Modules.StorageDb;
2246
2338
  if (StorageDb) {
2247
2339
  const record = await StorageDb.getRecord('sessions', runSessionId);
2248
2340
  if (record) {
@@ -2270,7 +2362,7 @@
2270
2362
  } catch (error) {
2271
2363
  if (runCancellationRequested || isRunCancellationError(error)) {
2272
2364
  appendRunCancelledReport();
2273
- finishRunView(tx('Cancelled', '已中断'), 'rejected');
2365
+ await finishRunView(tx('Cancelled', '已中断'), 'rejected');
2274
2366
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2275
2367
  return;
2276
2368
  }
@@ -2320,7 +2412,7 @@
2320
2412
  resultStatus: 'failed',
2321
2413
  blockedFiles: [{ path: 'task', reason: error.message }]
2322
2414
  });
2323
- finishRunView(tx('Task failed', '任务失败'), 'failed');
2415
+ await finishRunView(tx('Task failed', '任务失败'), 'failed');
2324
2416
  } finally {
2325
2417
  const settledView = currentRunView;
2326
2418
  const settledRecord = settledView ? findRunRecord(settledView.recordId, settledView.sessionId) : null;
@@ -2334,13 +2426,13 @@
2334
2426
  setRunning(false);
2335
2427
  nativeChannel.clearActiveRequest();
2336
2428
  stopRunElapsedTick();
2337
- currentRunView = null;
2338
- runCancellationRequested = false;
2339
- runCancellationController = null;
2340
2429
  if (isExperimentalOtEnabled()) {
2341
2430
  await resumeOtWarmMirror('run-settled');
2342
2431
  }
2343
- await saveState().catch(() => {});
2432
+ await flushQueuedSaveState().catch(() => {});
2433
+ currentRunView = null;
2434
+ runCancellationRequested = false;
2435
+ runCancellationController = null;
2344
2436
  activeTurnControl.release(settlement.requestId);
2345
2437
  if (settlement.requestId) {
2346
2438
  activeTurnControl.acknowledge(settlement.requestId).catch(() => {});
@@ -2357,7 +2449,8 @@
2357
2449
  submittedCustomInstructions,
2358
2450
  submittedSkillLoadingSettings,
2359
2451
  submittedAttachments,
2360
- submittedSkillInvocation
2452
+ submittedSkillInvocation,
2453
+ executionSnapshot
2361
2454
  }) {
2362
2455
  appendRunEvent({
2363
2456
  title: tx('Starting the Codex skill installer.', '正在启动 Codex skill installer。'),
@@ -2369,9 +2462,9 @@
2369
2462
  projectId: getCurrentProjectId(),
2370
2463
  mode: submittedMode,
2371
2464
  task,
2372
- model: state.model,
2373
- reasoningEffort: state.reasoningEffort,
2374
- speedTier: state.speedTier,
2465
+ model: executionSnapshot.model,
2466
+ reasoningEffort: executionSnapshot.reasoningEffort,
2467
+ speedTier: executionSnapshot.speedTier,
2375
2468
  session: state.session,
2376
2469
  threadId: codexThreadId || undefined,
2377
2470
  customInstructions: submittedCustomInstructions || undefined,
@@ -2392,7 +2485,7 @@
2392
2485
  if (runCancellationRequested || isRunCancellationError(response.error)) {
2393
2486
  appendRunCancelledReport();
2394
2487
  await finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2395
- finishRunView(tx('Cancelled', '已中断'), 'rejected');
2488
+ await finishRunView(tx('Cancelled', '已中断'), 'rejected');
2396
2489
  return;
2397
2490
  }
2398
2491
  const translated = translateRawError(response.error.message, { mode: submittedMode, locale: getLocale() });
@@ -2425,7 +2518,7 @@
2425
2518
  resultStatus: 'failed',
2426
2519
  blockedFiles: [{ path: 'skill-installer', reason: response.error?.code || response.error?.message || 'native_error' }]
2427
2520
  });
2428
- finishRunView(tx('Skill installer failed', 'Skill installer 失败'), 'failed');
2521
+ await finishRunView(tx('Skill installer failed', 'Skill installer 失败'), 'failed');
2429
2522
  return;
2430
2523
  }
2431
2524
 
@@ -2439,7 +2532,7 @@
2439
2532
  mode: submittedMode
2440
2533
  });
2441
2534
  await finalizeAuditRecord(runAuditDraft, { resultStatus: 'completed' });
2442
- finishRunView(tx('Skill installer completed', 'Skill installer 已完成'), 'completed');
2535
+ await finishRunView(tx('Skill installer completed', 'Skill installer 已完成'), 'completed');
2443
2536
 
2444
2537
  const runSessionForHistory = findSessionById(runSessionId) || getActiveSession(state);
2445
2538
  const rawAssistantMessage = assistantMessage;
@@ -2463,7 +2556,7 @@
2463
2556
  const returnedThreadId = response.result?.threadId || '';
2464
2557
  if (returnedThreadId && returnedThreadId !== codexThreadId) {
2465
2558
  updateSessionById(runSessionId, { codexThreadId: returnedThreadId });
2466
- const StorageDb = window.CodexOverleafStorageDb;
2559
+ const StorageDb = Modules.StorageDb;
2467
2560
  if (StorageDb) {
2468
2561
  const record = await StorageDb.getRecord('sessions', runSessionId);
2469
2562
  if (record) {
@@ -2562,7 +2655,7 @@
2562
2655
  const finishTitle = requireReviewing
2563
2656
  ? tx('Not started: could not enable Track Changes', '未开始:无法开启留痕')
2564
2657
  : tx('Not started: could not switch to Editing', '未开始:无法切换到 Editing');
2565
- finishRunView(finishTitle, 'failed');
2658
+ await finishRunView(finishTitle, 'failed');
2566
2659
  return {
2567
2660
  ok: false,
2568
2661
  reason,
@@ -2597,7 +2690,7 @@
2597
2690
  });
2598
2691
  }
2599
2692
 
2600
- const runGuidanceController = window.CodexOverleafRunGuidanceController.create({
2693
+ const runGuidanceController = Modules.RunGuidanceController.create({
2601
2694
  findRunRecord, appendRunEvent, getCurrentRunView: () => currentRunView,
2602
2695
  renderRunEvent, cssEscape, bumpUnreadIfDetached
2603
2696
  });
@@ -2612,11 +2705,10 @@
2612
2705
  readInputs: readPanelInputs,
2613
2706
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2614
2707
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
2615
- capturePayload: () => ({
2616
- mode: state.mode,
2617
- autoRecompile: state.autoRecompile !== false,
2618
- requireReviewing: state.requireReviewing,
2619
- focusFiles: getActiveFocusFiles()
2708
+ capturePayload: () => RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
2709
+ prepareClaim: () => providerSettingsCoordinator.ensureLoaded(),
2710
+ resolveExecutionSnapshot: snapshot => RunExecutionSnapshot.resolveForExecution(snapshot, {
2711
+ catalog: providerSettingsCoordinator.getCatalog()
2620
2712
  }),
2621
2713
  clearTask: () => {
2622
2714
  panel.querySelector('[data-task]').value = '';
@@ -2624,15 +2716,11 @@
2624
2716
  autosizeTaskTextarea();
2625
2717
  syncComposerSendAvailability();
2626
2718
  },
2627
- applyQueuedInput: item => {
2628
- state.autoRecompile = item.payload?.autoRecompile !== false;
2629
- state = updateActiveSession(state, {
2630
- task: item.text,
2631
- mode: item.payload?.mode || state.mode,
2632
- requireReviewing: item.payload?.requireReviewing !== false,
2633
- focusFiles: item.payload?.focusFiles || []
2634
- });
2635
- applyStateToPanel();
2719
+ applyQueuedInput: async item => {
2720
+ RunExecutionSnapshot.resolveForExecution(
2721
+ RunExecutionSnapshot.fromQueuePayload(item.payload),
2722
+ { catalog: providerSettingsCoordinator.getCatalog() }
2723
+ );
2636
2724
  },
2637
2725
  run: item => runTask({ queuedInput: item }),
2638
2726
  sendSteer: params => sendBackgroundNative({ method: 'codex.steer', params }),
@@ -2643,13 +2731,33 @@
2643
2731
  removeGuidance: runGuidanceController.remove,
2644
2732
  toast: (message, status) => showPluginToast(message, { status }),
2645
2733
  tr,
2646
- save: () => saveState(),
2647
- saveSoon: () => saveStateSoon(),
2734
+ save: persistenceOptions => saveState(persistenceOptions),
2735
+ saveSoon: persistenceOptions => saveStateSoon(120, persistenceOptions),
2648
2736
  randomUUID: () => crypto.randomUUID()
2649
2737
  });
2650
2738
  runQueueScheduler = runInputCoordinator.scheduler;
2651
2739
  }
2652
2740
 
2741
+ function captureCurrentExecutionSnapshot(submitted = {}) {
2742
+ const requestedProviderId = submitted.providerId || state?.providerId || 'builtin';
2743
+ const selection = providerSettingsCoordinator.getRunSelection(requestedProviderId);
2744
+ const providerId = selection?.providerId || requestedProviderId;
2745
+ return RunExecutionSnapshot.capture({
2746
+ mode: submitted.mode ?? state?.mode,
2747
+ providerId,
2748
+ providerRevision: selection?.providerRevision,
2749
+ model: submitted.model ?? state?.model,
2750
+ reasoningEffort: submitted.reasoningEffort ?? state?.reasoningEffort,
2751
+ speedTier: submitted.speedTier ?? state?.speedTier,
2752
+ autoRecompile: submitted.autoRecompile ?? state?.autoRecompile !== false,
2753
+ requireReviewing: submitted.requireReviewing ?? state?.requireReviewing === true,
2754
+ focusFiles: submitted.focusFiles ?? getActiveFocusFiles()
2755
+ }, {
2756
+ source: 'submitted',
2757
+ requireProviderRevision: providerId !== 'builtin'
2758
+ });
2759
+ }
2760
+
2653
2761
  function queueComposerInput() {
2654
2762
  return runInputCoordinator?.queueComposerInput();
2655
2763
  }
@@ -2716,11 +2824,7 @@
2716
2824
  }
2717
2825
 
2718
2826
  function cancelActivePageBridgeRequests() {
2719
- const handlers = Array.from(activePageBridgeCancellationHandlers.values());
2720
- activePageBridgeCancellationHandlers.clear();
2721
- for (const cancel of handlers) {
2722
- cancel();
2723
- }
2827
+ pageBridgeClient.cancelActiveRequests();
2724
2828
  }
2725
2829
 
2726
2830
  // Recovery path for the case where a previous Codex run leaked the
@@ -2884,9 +2988,11 @@
2884
2988
  skillInvocation,
2885
2989
  submittedMode
2886
2990
  } = {}) {
2991
+ const executionSnapshot = currentRunView?.executionSnapshot || captureCurrentExecutionSnapshot();
2992
+ const submittedState = RunExecutionSnapshot.applyToState(state, executionSnapshot);
2887
2993
  const params = runController.buildCodexRunParams({
2888
2994
  currentProjectId: getCurrentProjectId(),
2889
- state,
2995
+ state: submittedState,
2890
2996
  task,
2891
2997
  project,
2892
2998
  useExistingMirror,
@@ -2913,17 +3019,17 @@
2913
3019
  ...params,
2914
3020
  clientRunId: currentRunView?.recordId || '',
2915
3021
  clientSessionId: currentRunView?.sessionId || '',
2916
- providerSelection: providerSettingsCoordinator.getRunSelection(state?.providerId)
3022
+ providerSelection: RunExecutionSnapshot.toProviderSelection(executionSnapshot)
2917
3023
  };
2918
3024
  }
2919
3025
 
2920
3026
  async function sendTrackedCodexRun(params) {
2921
- const compatibilityGate = await ensureNativeCompatibilityForMethod('codex.run');
3027
+ const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run');
2922
3028
  if (!compatibilityGate.ok) {
2923
3029
  return compatibilityGate.response;
2924
3030
  }
2925
- throwIfCancelledBeforeNativeDispatch('codex.run');
2926
- const tracked = nativeChannel.sendNativeTracked(attachNativeCompatibilityEvidence({
3031
+ throwIfRunCancellationRequested();
3032
+ const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
2927
3033
  method: 'codex.run',
2928
3034
  params
2929
3035
  }, compatibilityGate.compatibility));
@@ -3003,19 +3109,23 @@
3003
3109
  }
3004
3110
 
3005
3111
  async function createAuditDraftForRun(input = {}) {
3006
- const StorageDb = window.CodexOverleafStorageDb;
3112
+ const StorageDb = Modules.StorageDb;
3007
3113
  if (!StorageDb || !AuditRecords) {
3008
3114
  return null;
3009
3115
  }
3116
+ const executionSnapshot = input.executionSnapshot
3117
+ || currentRunView?.executionSnapshot
3118
+ || captureCurrentExecutionSnapshot();
3010
3119
  const draft = AuditRecords.buildAuditDraftRecord({
3011
3120
  projectId: getCurrentProjectId(),
3012
3121
  sessionId: input.sessionId || '',
3013
3122
  turnId: currentRunView?.recordId || '',
3014
- mode: input.mode || state?.mode || '',
3015
- ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
3016
- model: state?.model || '',
3017
- reasoningEffort: state?.reasoningEffort || '',
3018
- speedTier: state?.speedTier || '',
3123
+ mode: input.mode || executionSnapshot.mode,
3124
+ ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot.providerId),
3125
+ providerRevision: executionSnapshot.providerRevision,
3126
+ model: executionSnapshot.model,
3127
+ reasoningEffort: executionSnapshot.reasoningEffort,
3128
+ speedTier: executionSnapshot.speedTier,
3019
3129
  task: input.task || '',
3020
3130
  focusFiles: input.focusFiles || [],
3021
3131
  selectedSkillIds: input.selectedSkillIds || []
@@ -3037,7 +3147,7 @@
3037
3147
  }
3038
3148
 
3039
3149
  async function finalizeAuditDraftPartial(draft, updates = {}) {
3040
- const StorageDb = window.CodexOverleafStorageDb;
3150
+ const StorageDb = Modules.StorageDb;
3041
3151
  if (!StorageDb || !draft) {
3042
3152
  return draft;
3043
3153
  }
@@ -3048,7 +3158,7 @@
3048
3158
  }
3049
3159
 
3050
3160
  async function finalizeAuditRecord(draft, updates = {}) {
3051
- const StorageDb = window.CodexOverleafStorageDb;
3161
+ const StorageDb = Modules.StorageDb;
3052
3162
  if (!StorageDb || !AuditRecords || !draft) {
3053
3163
  return null;
3054
3164
  }
@@ -3743,10 +3853,6 @@
3743
3853
  return diffReviewPanel.createDiffReviewElement(syncChanges, options);
3744
3854
  }
3745
3855
 
3746
- function renderDiffReview(syncChanges) {
3747
- return diffReviewPanel.renderDiffReview(syncChanges);
3748
- }
3749
-
3750
3856
  function renderReadOnlyDiffReview(syncChanges, title = tr('diffWrittenChangesTitle')) {
3751
3857
  return diffReviewPanel.renderReadOnlyDiffReview(syncChanges, title);
3752
3858
  }
@@ -3843,8 +3949,8 @@
3843
3949
  }
3844
3950
 
3845
3951
  function normalizeSafeProjectPath(value) {
3846
- if (window.CodexOverleafProjectFiles?.normalizeSafeProjectPath) {
3847
- return window.CodexOverleafProjectFiles.normalizeSafeProjectPath(value);
3952
+ if (Modules.ProjectFiles?.normalizeSafeProjectPath) {
3953
+ return Modules.ProjectFiles.normalizeSafeProjectPath(value);
3848
3954
  }
3849
3955
  return String(value || '')
3850
3956
  .replace(/\s+/g, ' ')
@@ -4123,6 +4229,7 @@
4123
4229
  // `runProjectId` is that it survives navigation; `activeProjectId` does not.
4124
4230
  let activeProjectId = null;
4125
4231
  let lastSpaPathname = '';
4232
+ let spaRouteGeneration = 0;
4126
4233
 
4127
4234
  function cancelPendingWritebacks(prevProjectId) {
4128
4235
  // The codebase does not maintain a separate pending-writeback queue;
@@ -4172,6 +4279,17 @@
4172
4279
  });
4173
4280
  }
4174
4281
 
4282
+ function getScopedPersistenceCoordinator() {
4283
+ if (scopedPersistenceCoordinator) return scopedPersistenceCoordinator;
4284
+ scopedPersistenceCoordinator = Modules.ScopedPersistenceCoordinator?.createController({
4285
+ Transaction: Modules.ScopedPersistenceTransaction,
4286
+ chromeApi: chrome,
4287
+ sessionStorage: window.sessionStorage,
4288
+ getAccountScopeId: () => window.codexOverleafDeriveAccountScopeId?.() || ''
4289
+ });
4290
+ return scopedPersistenceCoordinator;
4291
+ }
4292
+
4175
4293
  async function reloadProjectRunHistory(newProjectId) {
4176
4294
  // Rebind the storage key to the new project and reload the panel state
4177
4295
  // from chrome.storage.local so the per-project session list reflects
@@ -4265,21 +4383,34 @@
4265
4383
  } catch (_error) { /* swallow */ }
4266
4384
  }
4267
4385
 
4268
- function onSpaRouteChange() {
4269
- const url = window.location;
4386
+ async function onSpaRouteChange() {
4387
+ const generation = ++spaRouteGeneration;
4388
+ const url = new URL(window.location.href);
4389
+ const pathname = url.pathname;
4390
+ const previousProjectId = activeProjectId;
4391
+ const previousAccountScopeId = cachedAccountScopeId;
4392
+ const nextProjectId = isProjectEditorRoute(url)
4393
+ ? url.pathname.match(/^\/project\/([^/?#]+)/)[1]
4394
+ : null;
4395
+ if (nextProjectId !== activeProjectId) {
4396
+ leaveActiveProject(nextProjectId);
4397
+ }
4270
4398
  // Recompute the account scope on every route change (spec §5.2 per-
4271
4399
  // project derivation: account menu is global chrome and should be
4272
4400
  // readable on per-project URLs too).
4273
- refreshAccountScopeId().catch(() => { /* fail-closed handled inside */ });
4401
+ await refreshAccountScopeId();
4402
+ if (generation !== spaRouteGeneration || pathname !== window.location.pathname) {
4403
+ return;
4404
+ }
4274
4405
  if (isProjectEditorRoute(url)) {
4275
- const newId = url.pathname.match(/^\/project\/([^/?#]+)/)[1];
4276
- if (newId !== activeProjectId) {
4277
- leaveActiveProject(newId);
4278
- enterProject(newId);
4406
+ if (
4407
+ nextProjectId !== previousProjectId
4408
+ || cachedAccountScopeId !== previousAccountScopeId
4409
+ ) {
4410
+ enterProject(nextProjectId);
4279
4411
  }
4280
4412
  renderPerProjectVariant();
4281
4413
  } else {
4282
- leaveActiveProject(null);
4283
4414
  // Variant render is async (it awaits the IndexedDB query); the hook
4284
4415
  // does not await — subsequent renders pick up the rebound state.
4285
4416
  renderRecentProjectsVariant().catch(() => { /* swallow */ });
@@ -4303,11 +4434,9 @@
4303
4434
  return;
4304
4435
  }
4305
4436
  lastSpaPathname = next;
4306
- try {
4307
- onSpaRouteChange();
4308
- } catch (_error) {
4437
+ onSpaRouteChange().catch(() => {
4309
4438
  // Route-hook failures must never crash the page.
4310
- }
4439
+ });
4311
4440
  };
4312
4441
  const wrap = name => {
4313
4442
  const original = window.history[name];
@@ -4327,103 +4456,16 @@
4327
4456
  lastSpaPathname = window.location.pathname;
4328
4457
  }
4329
4458
 
4330
- // -----------------------------------------------------------------------
4331
- // Post-navigation run settlement (spec §5.7.1 + plan Step 6).
4332
- // -----------------------------------------------------------------------
4333
- //
4334
- // When a Codex run completes after the user navigated away from
4335
- // run.runProjectId, classify the outcome and persist the run on the
4336
- // ORIGINAL project's record (NOT activeProjectId — that's the whole
4337
- // point of the T2 immutability contract).
4338
4459
  function settleRunAfterNavigation(run, runResult) {
4339
- if (activeProjectId === run.runProjectId) {
4340
- // User is still on the project — normal settlement path; the caller
4341
- // continues unchanged.
4342
- return null;
4343
- }
4344
- // Welcome-panel + write-guard:
4345
- // accept the full `syncOutcome` (not just a pre-flattened skipped list).
4346
- // The previous shape — only `{ skipped: [...] }` from
4347
- // `collectRunResultSkipped` — silently classified runs as
4348
- // `background_completed` when (a) the skipped entries' codes were
4349
- // outside a small allow-list (e.g. delete_confirmation_rejected,
4350
- // governance_blocked, binary_confirmation_rejected) or (b) the
4351
- // syncOutcome was an early-return branch with
4352
- // `hasSkippedOperations === true` but no `applied` (which flattens to
4353
- // an empty skipped list).
4354
- //
4355
- // Spec §5.7.1 classification:
4356
- // 1. Any skipped entry with failure.code IN
4357
- // { 'aborted_project_changed', 'editor_project_id_unavailable' }
4358
- // → 'abandoned_after_navigation'.
4359
- // 2. Else if syncOutcome.hasSkippedOperations === true OR any skipped
4360
- // entry exists (code-bearing or not)
4361
- // → 'needs_review_after_navigation'.
4362
- // (Spec defense-in-depth: when in doubt, claim needs_review rather
4363
- // than background_completed.)
4364
- // 3. Else if at least one applied write AND no skipped/needs_review/
4365
- // failed entries AND post-write verification was clean
4366
- // → 'background_completed'.
4367
- // 4. Else (no skipped, no applied) — true no-op run (e.g. Ask-mode
4368
- // where there was nothing to write) → 'background_completed'.
4369
- const skipped = collectRunResultSkipped(runResult);
4370
- const wasGuardAborted = skipped.some(entry => {
4371
- const code = entry && entry.result && entry.result.code;
4372
- return code === 'aborted_project_changed' || code === 'editor_project_id_unavailable';
4460
+ return WritebackSettlement.settlePostNavigation({
4461
+ run,
4462
+ runResult,
4463
+ navigation: { occurred: activeProjectId !== run.runProjectId }
4373
4464
  });
4374
- if (wasGuardAborted) {
4375
- return 'abandoned_after_navigation';
4376
- }
4377
- const hasSkippedOperations = runResult && runResult.hasSkippedOperations === true;
4378
- if (hasSkippedOperations || skipped.length > 0) {
4379
- // Rule 2: ANY skipped entry — code-bearing or not — and/or the
4380
- // outcome flag — yields needs_review_after_navigation. This is the
4381
- // defense-in-depth net for skip codes outside the recognized list
4382
- // (delete_confirmation_rejected, governance_blocked,
4383
- // binary_confirmation_rejected, etc.) and for early-return branches
4384
- // where `applied` is absent but `hasSkippedOperations` is true.
4385
- return 'needs_review_after_navigation';
4386
- }
4387
- // Rule 3 / 4: no skipped, no failed. Either at least one clean applied
4388
- // write (post-write verification implicitly clean because rule 2
4389
- // already caught write_observed_mismatch / accept_not_verified /
4390
- // undo_not_verified / tracked_changes_remain via their skipped-entry
4391
- // form), OR a true no-op run with nothing to write. Both classify as
4392
- // background_completed per spec §5.7.1 rule 4.
4393
- return 'background_completed';
4394
- }
4395
-
4396
- // Flatten the `applied.skipped` arrays from a syncOutcome into the
4397
- // shape `settleRunAfterNavigation` expects: `{ skipped: [{ result: { code } }] }`.
4398
- // Accepts both shapes:
4399
- // 1. The full syncOutcome from `applySyncChangesToOverleaf` — reads
4400
- // `syncOutcome.applied.skipped`.
4401
- // 2. A pre-flattened shape `{ skipped: [...] }` — used by tests and by
4402
- // historical call sites.
4403
- // Spec §5.7.1: settleRunAfterNavigation now consumes the full
4404
- // syncOutcome directly, but this helper remains useful for the
4405
- // applied-array path AND keeps the legacy test surface intact.
4465
+ }
4466
+
4406
4467
  function collectRunResultSkipped(syncOutcome) {
4407
- if (!syncOutcome) {
4408
- return [];
4409
- }
4410
- const skipped = [];
4411
- const applied = syncOutcome.applied;
4412
- if (applied && Array.isArray(applied.skipped)) {
4413
- for (const entry of applied.skipped) {
4414
- if (entry) {
4415
- skipped.push(entry);
4416
- }
4417
- }
4418
- }
4419
- if (Array.isArray(syncOutcome.skipped)) {
4420
- for (const entry of syncOutcome.skipped) {
4421
- if (entry) {
4422
- skipped.push(entry);
4423
- }
4424
- }
4425
- }
4426
- return skipped;
4468
+ return WritebackSettlement.collectRunResultSkipped(syncOutcome);
4427
4469
  }
4428
4470
 
4429
4471
  // Persist the post-navigation status to the run's original
@@ -4438,50 +4480,73 @@
4438
4480
  // `reloadProjectRunHistory`), so the in-memory mutation would not
4439
4481
  // reach the right record. Go directly through StorageDb to update
4440
4482
  // the original project's session record by id.
4441
- async function persistPostNavigationRunStatus(run, postNavigationStatus) {
4483
+ async function persistPostNavigationRunStatus(run, postNavigationStatus, settlementResult) {
4442
4484
  if (!run || !postNavigationStatus) {
4443
- return;
4485
+ return { ok: false, reason: 'invalid_post_navigation_settlement' };
4444
4486
  }
4445
4487
  const postNavigationStatusText = getPostNavigationRunStatusText(postNavigationStatus);
4446
- run.status = postNavigationStatus;
4447
- run.statusText = postNavigationStatusText;
4448
- run.finishedAt = run.finishedAt || new Date().toISOString();
4449
- if (currentRunView && currentRunView.runProjectId === activeProjectId) {
4488
+ Object.assign(run, WritebackSettlement.applySettlementTransition(
4489
+ run,
4490
+ WritebackSettlement.transitionPostNavigationStatus({
4491
+ status: postNavigationStatus,
4492
+ statusText: postNavigationStatusText,
4493
+ finishedAt: run.finishedAt || new Date().toISOString()
4494
+ })
4495
+ ));
4496
+ if (
4497
+ currentRunView
4498
+ && currentRunView.runProjectId === activeProjectId
4499
+ && currentRunView.runAccountScopeId === cachedAccountScopeId
4500
+ ) {
4450
4501
  // Same project still active — the run record is reachable through the
4451
4502
  // normal state and the regular save path will persist it.
4452
4503
  saveStateSoon();
4453
- return;
4504
+ return { ok: true, lane: 'active_project' };
4454
4505
  }
4455
4506
  // Different project. Update the original session's run record directly.
4456
4507
  try {
4457
- const StorageDb = window.CodexOverleafStorageDb;
4458
- if (!StorageDb) {
4459
- return;
4460
- }
4461
4508
  const sessionId = currentRunView ? currentRunView.sessionId : '';
4462
4509
  if (!sessionId) {
4463
- return;
4464
- }
4465
- const record = await StorageDb.getRecord('sessions', sessionId);
4466
- if (!record) {
4467
- return;
4510
+ throw Object.assign(new Error('Post-navigation settlement has no original session identity.'), {
4511
+ code: 'post_navigation_settlement_persistence_failed',
4512
+ reason: 'session_scope_unavailable'
4513
+ });
4468
4514
  }
4469
- const runs = Array.isArray(record.runs) ? record.runs : [];
4470
- const targetRun = runs.find(item => item && item.id === run.id);
4471
- if (!targetRun) {
4472
- return;
4515
+ const persistence = Modules.PostNavigationSettlementPersistence;
4516
+ if (!persistence || typeof persistence.persistRequired !== 'function') {
4517
+ throw Object.assign(new Error('Post-navigation settlement persistence is unavailable.'), {
4518
+ code: 'post_navigation_settlement_persistence_failed',
4519
+ reason: 'persistence_module_unavailable'
4520
+ });
4473
4521
  }
4474
- targetRun.status = postNavigationStatus;
4475
- targetRun.statusText = postNavigationStatusText;
4476
- targetRun.finishedAt = run.finishedAt;
4477
- record.lastActivityAt = new Date().toISOString();
4478
- record.updatedAt = record.lastActivityAt;
4479
- await StorageDb.putRecord('sessions', record);
4480
- } catch (_error) {
4481
- // Settlement persistence is best-effort. The original project's
4482
- // existing record still holds the run; the worst-case is that the
4483
- // dashboard badge does not show the post-navigation reclassification
4484
- // for this one run.
4522
+ return await persistence.persistRequired({
4523
+ StorageDb: Modules.StorageDb,
4524
+ PersistenceCoordinator: getScopedPersistenceCoordinator(),
4525
+ WritebackSettlement,
4526
+ projectId: currentRunView?.runProjectId || '',
4527
+ accountScopeId: currentRunView?.runAccountScopeId || '',
4528
+ sessionId,
4529
+ runId: run.id,
4530
+ status: postNavigationStatus,
4531
+ statusText: postNavigationStatusText,
4532
+ finishedAt: run.finishedAt,
4533
+ settlementResult
4534
+ });
4535
+ } catch (error) {
4536
+ const reason = String(error?.reason || error?.code || 'unknown_failure');
4537
+ run.postNavigationPersistenceFailure = {
4538
+ code: 'post_navigation_settlement_persistence_failed',
4539
+ reason,
4540
+ at: new Date().toISOString()
4541
+ };
4542
+ appendPlainLog(tx(
4543
+ `Could not persist the completed run to its original project (${reason}). Reopen that project to recover the interrupted record.`,
4544
+ `无法将已完成运行保存到原项目(${reason})。请重新打开该项目以恢复中断记录。`
4545
+ ));
4546
+ return {
4547
+ ok: false,
4548
+ reason
4549
+ };
4485
4550
  }
4486
4551
  }
4487
4552
 
@@ -4510,7 +4575,7 @@
4510
4575
  getCachedAccountScopeId: () => cachedAccountScopeId,
4511
4576
  // T5 surfaces: variant renderer + helpers. Tests drive
4512
4577
  // `renderRecentProjectsVariant` against a stubbed `panel` and
4513
- // `window.CodexOverleafStorageDb` to assert layout / row behavior.
4578
+ // `Modules.StorageDb` to assert layout / row behavior.
4514
4579
  renderRecentProjectsVariant,
4515
4580
  renderRecentProjectRow,
4516
4581
  renderStatusBadge,
@@ -4550,80 +4615,11 @@
4550
4615
  mode,
4551
4616
  operations: [],
4552
4617
  applyResults: [],
4553
- nextStep: tx('Continue the conversation, or switch to Suggest/Auto to let Codex edit files.', '可以继续追问,或切换到建议修改/自动写入后让 Codex 修改文件。')
4618
+ nextStep: tx('Continue the conversation, or switch to Auto to let Codex edit files.', '可以继续追问,或切换到自动写入后让 Codex 修改文件。')
4554
4619
  });
4555
4620
  return { status: tr('modeAsk'), summaryLine };
4556
4621
  }
4557
4622
 
4558
- if (result.status === 'requires_task_confirmation') {
4559
- appendPlannedChangeSummary(result.summary, tx('Preparing changes', '准备修改'));
4560
- const approved = await showPluginConfirm({
4561
- title: tx('Apply these changes?', '应用这些修改?'),
4562
- message: formatSummary(tx('Change Summary', '修改摘要'), result.summary),
4563
- confirmLabel: tx('Apply changes', '应用修改'),
4564
- cancelLabel: tr('confirmDefaultCancel')
4565
- });
4566
- if (!approved) {
4567
- appendLog(tx('Cancelled: Codex did not write any files.', '已取消:Codex 没有写入任何文件。'));
4568
- const summaryLine = appendChangeSummary({ notes, summary: result.summary, status: 'rejected' });
4569
- appendCompletionReport({
4570
- conclusion: tx('You cancelled this change. Codex did not write files.', '你取消了这轮修改,Codex 没有写入文件。'),
4571
- status: 'rejected',
4572
- notes,
4573
- summary: result.summary,
4574
- userReport: result.userReport,
4575
- mode,
4576
- operations: [],
4577
- applyResults: [],
4578
- nextStep: tx('Adjust the task and run again, or switch to Ask first so Codex can explain the plan.', '可以调整任务描述后重新运行,或切到只问不改先让 Codex 解释方案。')
4579
- });
4580
- return { status: 'rejected', summaryLine };
4581
- }
4582
- const confirmed = result.planId
4583
- ? await sendNative({ method: 'task.confirm', params: { planId: result.planId } })
4584
- : { ok: true, result: { operations: result.operations || [] } };
4585
- if (!confirmed.ok) {
4586
- appendLog(`Confirm failed: ${confirmed.error.message}`);
4587
- const summaryLine = appendChangeSummary({ notes, operations: [], status: 'confirm failed' });
4588
- appendCompletionReport({
4589
- conclusion: tx('No writable changes were returned after confirmation.', '确认后没有拿到可写入的修改。'),
4590
- status: 'confirm failed',
4591
- notes,
4592
- userReport: result.userReport,
4593
- mode,
4594
- operations: [],
4595
- applyResults: [],
4596
- nextStep: confirmed.error.message
4597
- });
4598
- return { status: 'confirm failed', summaryLine };
4599
- }
4600
- const operations = confirmed.result.operations || [];
4601
- appendOperationsPreview(operations, tx('Confirmed; preparing to write', '用户已确认,准备写入'));
4602
- const applied = await applyTaskOperations(project, operations, { allowHighRisk: true });
4603
- appendApplyResult(applied);
4604
- recordUndoFromApply(project, applied);
4605
- const summaryLine = appendChangeSummary({
4606
- notes,
4607
- operations,
4608
- applyResults: [applied],
4609
- status: 'confirmed and applied'
4610
- });
4611
- appendCompletionReport({
4612
- conclusion: notes || tx('Changes were written after your confirmation.', '已按你的确认写入修改。'),
4613
- status: 'confirmed and applied',
4614
- notes,
4615
- userReport: result.userReport,
4616
- mode,
4617
- operations,
4618
- applyResults: [applied]
4619
- });
4620
- return {
4621
- status: 'confirmed and applied',
4622
- summaryLine,
4623
- hasSkippedOperations: hasSkippedApplyOperations([applied])
4624
- };
4625
- }
4626
-
4627
4623
  if (result.status === 'delete_plan_required') {
4628
4624
  const operations = result.operations || [];
4629
4625
  const applyResults = [];
@@ -4696,7 +4692,7 @@
4696
4692
 
4697
4693
  const operations = result.operations || [];
4698
4694
  appendOperationsPreview(operations, tx('Preparing to write', '准备写入'));
4699
- const applied = await applyTaskOperations(project, operations, { allowHighRisk: mode === 'confirm' });
4695
+ const applied = await applyTaskOperations(project, operations);
4700
4696
  appendApplyResult(applied);
4701
4697
  recordUndoFromApply(project, applied);
4702
4698
  appendLog(mode === 'auto' ? tx('Auto write task completed.', '自动写入任务完成。') : tx('Task completed.', '任务完成。'));
@@ -5016,9 +5012,6 @@
5016
5012
  if (mode === 'ask') {
5017
5013
  return tr('modeAsk');
5018
5014
  }
5019
- if (mode === 'confirm') {
5020
- return tr('modeConfirm');
5021
- }
5022
5015
  if (mode === 'auto') {
5023
5016
  return tr('modeAuto');
5024
5017
  }
@@ -5030,8 +5023,6 @@
5030
5023
  const labels = {
5031
5024
  completed: tx('Completed', '已完成'),
5032
5025
  rejected: tx('Cancelled', '已取消'),
5033
- 'confirm failed': tx('Confirmation failed', '确认失败'),
5034
- 'confirmed and applied': tx('Suggested changes applied', '已应用建议修改'),
5035
5026
  'applied with delete plan': tx('Applied with confirmed deletes', '已应用并删除确认项'),
5036
5027
  'applied without deletes': tx('Applied without deletes', '已应用非删除修改'),
5037
5028
  '只问不改': tr('modeAsk')
@@ -5225,7 +5216,7 @@
5225
5216
  }
5226
5217
 
5227
5218
  const mirrorStatus = await getMirrorFreshness();
5228
- const mirrorHealth = window.CodexOverleafMirrorHealth.classifyMirrorHealth(mirrorStatus);
5219
+ const mirrorHealth = Modules.MirrorHealth.classifyMirrorHealth(mirrorStatus);
5229
5220
  if (!mirrorStatus?.exists || !mirrorHealth.reusable) {
5230
5221
  return { useExistingMirror: false, reason: 'mirror_not_fresh', mirrorStatus };
5231
5222
  }
@@ -5254,7 +5245,7 @@
5254
5245
  return candidates
5255
5246
  .filter(file =>
5256
5247
  typeof file.content === 'string' &&
5257
- window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content) &&
5248
+ Modules.ProjectFiles.isUsableProjectFileContent(file.content) &&
5258
5249
  file.path &&
5259
5250
  !seen.has(file.path) &&
5260
5251
  seen.add(file.path)
@@ -5363,7 +5354,7 @@
5363
5354
  blocking.push('Full project snapshot was not captured.');
5364
5355
  }
5365
5356
 
5366
- const unusable = textFiles.filter(file => !window.CodexOverleafProjectFiles.isUsableProjectFileContent(file.content));
5357
+ const unusable = textFiles.filter(file => !Modules.ProjectFiles.isUsableProjectFileContent(file.content));
5367
5358
  if (unusable.length === textFiles.length) {
5368
5359
  blocking.push('Captured file contents are empty or still loading.');
5369
5360
  } else if (unusable.length) {
@@ -5401,545 +5392,84 @@
5401
5392
  }
5402
5393
 
5403
5394
  function sendNative(payload) {
5404
- return (async () => {
5405
- const compatibilityGate = await ensureNativeCompatibilityForMethod(payload?.method);
5406
- if (!compatibilityGate.ok) {
5407
- return compatibilityGate.response;
5408
- }
5409
- throwIfCancelledBeforeNativeDispatch(payload?.method);
5410
- return nativeChannel.sendNative(attachNativeCompatibilityEvidence(payload, compatibilityGate.compatibility));
5411
- })();
5395
+ return nativeCompatibilityController.sendNative(payload);
5412
5396
  }
5413
5397
 
5414
5398
  function sendBackgroundNative(payload) {
5415
- return (async () => {
5416
- const compatibilityGate = await ensureNativeCompatibilityForMethod(payload?.method);
5417
- if (!compatibilityGate.ok) {
5418
- return compatibilityGate.response;
5419
- }
5420
- throwIfCancelledBeforeNativeDispatch(payload?.method);
5421
- return nativeChannel.sendBackgroundNative(attachNativeCompatibilityEvidence(payload, compatibilityGate.compatibility));
5422
- })();
5423
- }
5424
-
5425
- function throwIfCancelledBeforeNativeDispatch(method) {
5426
- if (NATIVE_COMPATIBILITY_GATED_METHODS.has(method)) {
5427
- throwIfRunCancellationRequested();
5428
- }
5429
- }
5430
-
5431
- async function ensureNativeCompatibilityForMethod(method) {
5432
- const params = CodexOverleafCompatibility?.buildBridgePingParams
5433
- ? CodexOverleafCompatibility.buildBridgePingParams(getExtensionCompatibilityMetadata())
5434
- : {};
5435
- const response = await nativeChannel.sendBackgroundNative({
5436
- method: 'bridge.ping',
5437
- params
5438
- });
5439
- const compatibility = CodexOverleafCompatibility?.evaluateNativeCompatibility
5440
- ? CodexOverleafCompatibility.evaluateNativeCompatibility(response, getExtensionCompatibilityMetadata())
5441
- : fallbackNativeCompatibility(response);
5442
- const allowed = !NATIVE_COMPATIBILITY_GATED_METHODS.has(method) ||
5443
- (CodexOverleafCompatibility?.isNativeMethodAllowed
5444
- ? CodexOverleafCompatibility.isNativeMethodAllowed(method, compatibility)
5445
- : compatibility.status === 'ok');
5446
- if (allowed) {
5447
- return { ok: true, compatibility };
5448
- }
5449
-
5450
- const message = formatNativeCompatibilityBlockedMessage(method, compatibility);
5451
- const error = {
5452
- code: 'native_update_required',
5453
- message,
5454
- status: compatibility.status || 'unknown_native',
5455
- classification: getNativeCompatibilityClassification(compatibility),
5456
- installCommand: compatibility.installCommand || INSTALL_COMMAND,
5457
- updateCommand: compatibility.updateCommand || compatibility.installCommand || INSTALL_COMMAND,
5458
- currentNativeVersion: compatibility.currentNativeVersion || compatibility.nativeVersion || compatibility.native?.version || '',
5459
- requiredVersion: compatibility.requiredVersion || CodexOverleafCompatibility?.BUILD_TARGET_VERSION || '',
5460
- releaseUrl: compatibility.releaseUrl || ''
5461
- };
5462
- notifyNativeCompatibilityBlocked(error, compatibility);
5463
- return {
5464
- ok: false,
5465
- compatibility,
5466
- response: {
5467
- ok: false,
5468
- error
5469
- }
5470
- };
5471
- }
5472
-
5473
- function attachNativeCompatibilityEvidence(payload = {}, compatibility) {
5474
- if (!compatibility || !NATIVE_COMPATIBILITY_GATED_METHODS.has(payload?.method)) {
5475
- return payload;
5476
- }
5477
- return {
5478
- ...payload,
5479
- params: {
5480
- ...(payload.params || {}),
5481
- nativeCompatibility: compatibility
5482
- }
5483
- };
5484
- }
5485
-
5486
- function notifyNativeCompatibilityBlocked(error, compatibility = {}) {
5487
- const message = error?.message || String(error || '');
5488
- if (currentRunView) {
5489
- appendRunEvent({
5490
- title: message,
5491
- status: 'failed'
5492
- });
5493
- showNativeUpdateGuidanceModal(compatibility);
5494
- } else {
5495
- showNativeUpdateGuidanceModal(compatibility);
5496
- showPluginToast(message, { status: 'failed', sticky: true });
5497
- }
5399
+ return nativeCompatibilityController.sendBackgroundNative(payload);
5498
5400
  }
5499
5401
 
5500
5402
  async function refreshNativeCompatibilityBadge() {
5501
- if (!panelRendererInstance?.headerEl || !CodexOverleafCompatibility?.evaluateNativeCompatibility) {
5502
- return;
5503
- }
5504
- try {
5505
- const params = CodexOverleafCompatibility.buildBridgePingParams
5506
- ? CodexOverleafCompatibility.buildBridgePingParams(getExtensionCompatibilityMetadata())
5507
- : {};
5508
- const response = await sendBackgroundNative({ method: 'bridge.ping', params });
5509
- const compatibility = CodexOverleafCompatibility.evaluateNativeCompatibility(response, getExtensionCompatibilityMetadata());
5510
- const classification = getNativeCompatibilityClassification(compatibility);
5511
- if (classification === 'compatible') {
5512
- PanelRenderer.setBadge(panelRendererInstance.headerEl, { type: 'none' });
5513
- setDiagnosticsHealth('ok');
5514
- try {
5515
- window.localStorage.setItem('codexOverleafNativeEverOk', 'true');
5516
- } catch (_storageError) { /* private mode etc.; the wizard just stays eligible */ }
5517
- return;
5518
- }
5519
- // update-available reads as attention; anything else (incompatible /
5520
- // unsupported) reads as a hard problem on the diagnostics dot.
5521
- setDiagnosticsHealth(classification === 'update-available' ? 'warn' : 'fail');
5522
- PanelRenderer.setBadge(panelRendererInstance.headerEl, {
5523
- type: 'update',
5524
- tooltip: tx('Native host update available', 'Native host 可更新'),
5525
- onClick: () => showNativeUpdateGuidanceModal(compatibility)
5526
- });
5527
- // A never-installed host does NOT throw: background resolves
5528
- // {ok:false, native_connection_failed} and classification lands here.
5529
- if (!compatibility?.native?.version) {
5530
- maybePromptFirstRunSetup();
5531
- }
5532
- } catch (_error) {
5533
- setDiagnosticsHealth('fail');
5534
- // No ping response at all means the native host is missing or not
5535
- // running — telling a never-installed user an "update" exists is
5536
- // misleading; point them at setup instead.
5537
- PanelRenderer.setBadge(panelRendererInstance.headerEl, {
5538
- type: 'update',
5539
- tooltip: tx('Native host is not responding — click for setup steps', 'Native host 未响应——点击查看安装步骤'),
5540
- onClick: () => showNativeUpdateGuidanceModal(fallbackNativeCompatibility({ ok: false }))
5541
- });
5542
- maybePromptFirstRunSetup();
5543
- }
5544
- }
5545
-
5546
- // First-run wizard (v1.7): the native host is the one hard prerequisite,
5547
- // and pre-1.7 its absence was a 9px red dot. Auto-open the install guidance
5548
- // ONCE per browser profile; the flag is set before showing so a dismissal
5549
- // is respected forever (no nag loop).
5550
- function maybePromptFirstRunSetup() {
5551
- let storage = null;
5552
- try {
5553
- storage = window.localStorage;
5554
- if (!storage || storage.getItem('codexOverleafSetupPromptShown') === 'true') {
5555
- return;
5556
- }
5557
- // A profile that ever completed a successful ping is an installed user
5558
- // having a transient outage, not a first run — never auto-modal them.
5559
- if (storage.getItem('codexOverleafNativeEverOk') === 'true') {
5560
- return;
5561
- }
5562
- storage.setItem('codexOverleafSetupPromptShown', 'true');
5563
- } catch (_storageError) {
5564
- return;
5565
- }
5566
- showNativeUpdateGuidanceModal(fallbackNativeCompatibility({ ok: false }));
5403
+ return nativeCompatibilityController.refreshBadge();
5567
5404
  }
5568
5405
 
5569
5406
  function showNativeUpdateGuidanceModal(compatibility = {}) {
5570
- if (!panel) {
5571
- ensurePanelOpen();
5572
- }
5573
- const existing = panel.querySelector('[data-native-update-guidance]');
5574
- existing?.remove();
5575
-
5576
- const native = compatibility.native || {};
5577
- const extensionVersion = compatibility.extensionVersion || CodexOverleafCompatibility?.BUILD_TARGET_VERSION || '';
5578
- const nativeVersion = compatibility.currentNativeVersion || compatibility.nativeVersion || native.version || tx('missing', '未安装');
5579
- const command = compatibility.updateCommand || compatibility.installCommand || INSTALL_COMMAND;
5580
- const releaseUrl = compatibility.releaseUrl || '';
5581
-
5582
- const overlay = document.createElement('div');
5583
- overlay.className = 'codex-plugin-confirm';
5584
- overlay.dataset.nativeUpdateGuidance = 'true';
5585
- overlay.setAttribute('role', 'dialog');
5586
- overlay.setAttribute('aria-modal', 'true');
5587
- const nativeMissing = !native.version;
5588
- overlay.setAttribute('aria-label', nativeMissing
5589
- ? tx('Set up the native host', '安装 Native host')
5590
- : tx('Native host update available', 'Native host 可更新'));
5591
-
5592
- const card = document.createElement('section');
5593
- card.className = 'codex-plugin-confirm-card';
5594
-
5595
- const title = document.createElement('div');
5596
- title.className = 'codex-plugin-confirm-title';
5597
- title.textContent = nativeMissing
5598
- ? tx('One step left: install the native host', '还差一步:安装 Native host')
5599
- : tx('Native host update available', 'Native host 可更新');
5600
-
5601
- const body = document.createElement('div');
5602
- body.className = 'codex-plugin-confirm-body';
5603
- body.textContent = [
5604
- nativeMissing
5605
- ? tx(`Extension v${extensionVersion} is ready; Codex still needs its local bridge to read and edit this project.`, `扩展 v${extensionVersion} 已就绪;Codex 还需要本地桥接程序才能读写这个项目。`)
5606
- : tx(`Extension v${extensionVersion} / Native v${nativeVersion}`, `扩展 v${extensionVersion} / Native v${nativeVersion}`),
5607
- tx('Run the platform-specific command below, reload the extension, then refresh Overleaf.', '运行下面的平台命令,重新加载扩展,然后刷新 Overleaf。')
5608
- ].join('\n\n');
5609
-
5610
- const commandCode = document.createElement('code');
5611
- commandCode.textContent = command;
5612
- commandCode.style.display = 'block';
5613
- commandCode.style.whiteSpace = 'pre-wrap';
5614
- commandCode.style.marginTop = '0.75rem';
5615
- body.append(commandCode);
5616
-
5617
- if (releaseUrl) {
5618
- const link = document.createElement('a');
5619
- link.href = releaseUrl;
5620
- link.target = '_blank';
5621
- link.rel = 'noopener noreferrer';
5622
- link.textContent = tx('Open GitHub Release', '打开 GitHub Release');
5623
- link.style.display = 'block';
5624
- link.style.marginTop = '0.75rem';
5625
- body.append(link);
5626
- }
5627
-
5628
- const actions = document.createElement('div');
5629
- actions.className = 'codex-plugin-confirm-actions';
5630
- const copy = document.createElement('button');
5631
- copy.type = 'button';
5632
- copy.className = 'codex-plugin-confirm-confirm';
5633
- copy.textContent = tx('Copy command', '复制命令');
5634
- copy.addEventListener('click', async () => {
5635
- await navigator.clipboard.writeText(command);
5636
- copy.textContent = tx('Copied', '已复制');
5637
- });
5638
- const close = document.createElement('button');
5639
- close.type = 'button';
5640
- close.className = 'codex-plugin-confirm-cancel';
5641
- close.textContent = tx('Close', '关闭');
5642
- close.addEventListener('click', () => overlay.remove());
5643
- actions.append(close, copy);
5644
-
5645
- card.append(title, body, actions);
5646
- overlay.append(card);
5647
- overlay.addEventListener('click', event => {
5648
- if (event.target === overlay) {
5649
- overlay.remove();
5650
- }
5651
- });
5652
- panel.append(overlay);
5653
- copy.focus();
5407
+ return nativeCompatibilityController.showUpdateGuidance(compatibility);
5654
5408
  }
5655
5409
 
5656
5410
  function scheduleFirstUseOnboardingTip() {
5657
- setTimeout(() => {
5658
- maybeShowFirstUseOnboardingTip().catch(() => {});
5659
- }, 250);
5660
- }
5661
-
5662
- async function maybeShowFirstUseOnboardingTip() {
5663
- if (!panel || (state?.sessions || []).length > 0) {
5664
- return;
5665
- }
5666
- const stored = await chrome.storage.local.get([ONBOARDING_TIP_STORAGE_KEY]);
5667
- if (stored?.[ONBOARDING_TIP_STORAGE_KEY]) {
5668
- return;
5669
- }
5670
- const modeSelect = panel.querySelector('[data-mode]');
5671
- if (!modeSelect) {
5672
- return;
5673
- }
5674
- const tip = document.createElement('div');
5675
- tip.className = 'codex-onboarding-tip';
5676
- tip.dataset.onboardingTip = 'true';
5677
- tip.setAttribute('role', 'status');
5678
- tip.textContent = tx(
5679
- 'Tip: Start with Ask mode to explore safely. Switch to Suggest to review edits before applying.',
5680
- '提示:先用 Ask 模式安全探索;需要修改时切到 Suggest,应用前可先审阅。'
5681
- );
5682
- Object.assign(tip.style, {
5683
- position: 'absolute',
5684
- zIndex: '2147483647',
5685
- maxWidth: '280px'
5686
- });
5687
- panel.append(tip);
5688
- const panelRect = panel.getBoundingClientRect();
5689
- const modeRect = modeSelect.getBoundingClientRect();
5690
- tip.style.left = `${Math.max(12, modeRect.left - panelRect.left)}px`;
5691
- tip.style.bottom = `${Math.max(72, panelRect.bottom - modeRect.top + 8)}px`;
5692
-
5693
- const dismiss = () => {
5694
- tip.remove();
5695
- chrome.storage.local.set({ [ONBOARDING_TIP_STORAGE_KEY]: true }).catch?.(() => {});
5696
- panel.removeEventListener('click', dismiss, true);
5697
- panel.removeEventListener('keydown', dismiss, true);
5698
- };
5699
- panel.addEventListener('click', dismiss, true);
5700
- panel.addEventListener('keydown', dismiss, true);
5701
- setTimeout(dismiss, 8000);
5702
- }
5703
-
5704
- function formatNativeCompatibilityBlockedMessage(method, compatibility = {}) {
5705
- const statusValue = compatibility.status || 'unknown_native';
5706
- const params = { method: method || 'native request' };
5707
- switch (statusValue) {
5708
- case 'native_too_old':
5709
- return tr('nativeCompatibilityBlockedNativeTooOld', params);
5710
- case 'extension_too_old':
5711
- return tr('nativeCompatibilityBlockedExtensionTooOld', params);
5712
- case 'protocol_unsupported':
5713
- return tr('nativeCompatibilityBlockedProtocol', params);
5714
- case 'native_unhealthy':
5715
- return tr('nativeCompatibilityBlockedUnhealthy', params);
5716
- case 'native_missing':
5717
- return tr('nativeCompatibilityBlockedMissing', params);
5718
- default:
5719
- return tr('nativeCompatibilityBlockedGeneric', params);
5720
- }
5411
+ nativeCompatibilityController.scheduleFirstUseOnboardingTip();
5721
5412
  }
5722
5413
 
5723
5414
  async function callPageBridge(method, params) {
5724
- try {
5725
- await pageBridgeReady;
5726
- } catch (error) {
5727
- return {
5728
- ok: false,
5729
- error: `Page bridge unavailable: ${error.message}`
5730
- };
5731
- }
5732
- const timeoutMs = getPageBridgeTimeoutMs(method);
5733
- return sendPageBridgeRequest(method, params, {
5734
- timeoutMs
5735
- });
5736
- }
5737
-
5738
- function sendPageBridgeRequest(method, params, options = {}) {
5739
- const id = crypto.randomUUID();
5740
- return new Promise((resolve, reject) => {
5741
- const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 8000;
5742
- const cancellable = CANCELLABLE_PAGE_BRIDGE_METHODS.has(method);
5743
- let settled = false;
5744
- let timeout = null;
5745
-
5746
- function cleanup() {
5747
- if (timeout !== null) {
5748
- window.clearTimeout(timeout);
5749
- }
5750
- window.removeEventListener('message', onMessage);
5751
- if (cancellable) {
5752
- activePageBridgeCancellationHandlers.delete(id);
5753
- }
5754
- }
5755
-
5756
- function resolveOnce(value) {
5757
- if (settled) {
5758
- return;
5759
- }
5760
- settled = true;
5761
- cleanup();
5762
- resolve(value);
5763
- }
5764
-
5765
- function rejectOnce(error) {
5766
- if (settled) {
5767
- return;
5768
- }
5769
- settled = true;
5770
- cleanup();
5771
- reject(error);
5772
- }
5773
-
5774
- function cancelRequest() {
5775
- const error = new Error('Codex run was cancelled by the user');
5776
- error.code = 'codex_cancelled';
5777
- error.cancelled = true;
5778
- rejectOnce(error);
5779
- }
5780
-
5781
- timeout = window.setTimeout(() => {
5782
- resolveOnce({ ok: false, error: 'Page bridge timed out' });
5783
- }, timeoutMs);
5784
-
5785
- function onMessage(event) {
5786
- if (event.source !== window
5787
- || event.origin !== window.location.origin
5788
- || event.data?.source !== 'codex-overleaf/page'
5789
- || event.data?.pageBridgeVersion !== CodexOverleafCompatibility?.BUILD_TARGET_VERSION
5790
- || event.data?.pageBridgeRevision !== PAGE_BRIDGE_SCRIPT_REVISION
5791
- || event.data.id !== id) {
5792
- return;
5793
- }
5794
- resolveOnce(event.data.result);
5795
- }
5796
-
5797
- if (cancellable) {
5798
- activePageBridgeCancellationHandlers.set(id, cancelRequest);
5799
- if (runCancellationRequested) {
5800
- window.queueMicrotask(cancelRequest);
5801
- }
5802
- }
5803
-
5804
- window.addEventListener('message', onMessage);
5805
- // This capability narrows accidental/spoofed page-bridge calls. It is not
5806
- // a secret from malicious same-page Overleaf scripts because postMessage
5807
- // traffic is page-visible.
5808
- window.postMessage({
5809
- source: 'codex-overleaf/content',
5810
- id,
5811
- method,
5812
- params,
5813
- capability: PAGE_BRIDGE_CAPABILITY
5814
- }, window.location.origin);
5815
- });
5415
+ return pageBridgeClient.call(method, params);
5816
5416
  }
5817
5417
 
5818
5418
  function getPageBridgeTimeoutMs(method) {
5819
- if (method === 'getProjectSnapshot') {
5820
- return SNAPSHOT_PAGE_BRIDGE_TIMEOUT_MS;
5821
- }
5822
- if (method === 'getProjectFileList') {
5823
- return CONTEXT_FILE_LIST_PAGE_BRIDGE_TIMEOUT_MS;
5824
- }
5825
- if (method === 'triggerCompile' || method === 'getCompileLog') {
5826
- return COMPILE_PAGE_BRIDGE_TIMEOUT_MS;
5827
- }
5828
- if (method === 'rejectTrackedChanges' || method === 'acceptTrackedChanges') {
5829
- return 120000;
5830
- }
5831
- if (method === 'applyOperations') {
5832
- // Each per-op step inside writebackRouter.applyOperationsCore can wait
5833
- // up to 5s for openFileByPath (treeOperations.js:230, :259) plus up to
5834
- // 5s for waitForActiveEditorText plus reviewing/save-state polling.
5835
- // A multi-op write on a freshly-loaded Overleaf editor can run 15-30s
5836
- // in the wild. The pre-fix default of 8000ms timed out mid-write and
5837
- // left the page-side promise running uncontrolled — a 'zombie' write
5838
- // could still land after the content-side reported failure. 30s is a
5839
- // sane upper bound that covers the realistic slow path without
5840
- // letting a genuinely hung dispatch tie up the UI indefinitely.
5841
- return 30000;
5842
- }
5843
- return 8000;
5844
- }
5845
-
5846
- async function injectPageBridge() {
5847
- await injectScriptOnce('src/shared/reviewing.js', 'codex-overleaf-reviewing-script');
5848
- await injectScriptOnce('src/shared/projectFiles.js', 'codex-overleaf-project-files-script');
5849
- await injectScriptOnce('src/shared/compatibility.js', 'codex-overleaf-compatibility-page-script');
5850
- await injectScriptOnce('src/shared/staleGuard.js', 'codex-overleaf-stale-guard-script');
5851
- await injectScriptOnce('src/shared/compileAdapter.js', 'codex-overleaf-compile-adapter-script');
5852
- await injectScriptOnce('src/shared/governanceRules.js', 'codex-overleaf-governance-rules-script');
5853
- await injectScriptOnce('src/shared/sensitiveScan.js', 'codex-overleaf-sensitive-scan-script');
5854
- await injectScriptOnce('src/shared/auditRecords.js', 'codex-overleaf-audit-records-script');
5855
- await injectScriptOnce('src/page/saveState.js', 'codex-overleaf-save-state-script', { force: true });
5856
- await injectScriptOnce('src/page/overleafCapabilities.js', 'codex-overleaf-capabilities-script');
5857
- await injectScriptOnce('src/page/compileBridge.js', 'codex-overleaf-compile-bridge-script');
5858
- await injectScriptOnce('src/page/overleafEditor.js', 'codex-overleaf-editor-script');
5859
- await injectScriptOnce('src/page/overleafProjectSnapshot.js', 'codex-overleaf-project-snapshot-script');
5860
- await injectOptionalOtDependencies();
5861
- await injectScriptOnce('src/page/pageBridgeCapability.js', 'codex-overleaf-page-bridge-capability-script');
5862
- await injectScriptOnce('src/page/treeOperations.js', 'codex-overleaf-tree-operations-script', { force: true });
5863
- await injectScriptOnce('src/page/snapshotRouter.js', 'codex-overleaf-snapshot-router-script');
5864
- await injectScriptOnce('src/page/writeGuard.js', 'codex-overleaf-write-guard-script', { force: true });
5865
- // Phase 7 (v1.8.0): the tracked-changes lifecycle must be defined in the
5866
- // page world BEFORE writebackRouter.create() dereferences it — the vm
5867
- // test harnesses inject it manually, so only this list proves it loads.
5868
- await injectScriptOnce('src/page/trackedChangesLifecycle.js', 'codex-overleaf-tracked-changes-lifecycle-script', { force: true });
5869
- await injectScriptOnce('src/page/writebackRouter.js', 'codex-overleaf-writeback-router-script', { force: true });
5870
- await injectScriptOnce('src/pageBridge.js', 'codex-overleaf-page-bridge-script', { force: true });
5871
- await initializePageBridgeCapability();
5872
- }
5873
-
5874
- async function injectOptionalOtDependencies() {
5875
- try {
5876
- await injectScriptOnce('src/shared/otText.js', 'codex-overleaf-ot-text-script');
5877
- await injectScriptOnce('src/page/overleafRealtimeObserver.js', 'codex-overleaf-realtime-observer-script');
5878
- } catch (_error) {
5879
- // The page bridge has a read-only unavailable fallback for these optional OT helpers.
5880
- }
5419
+ return pageBridgeClient.resolveTimeoutMs(method);
5881
5420
  }
5882
5421
 
5883
- function injectScriptOnce(src, id, options = {}) {
5884
- return new Promise((resolve, reject) => {
5885
- const existing = document.getElementById(id);
5886
- if (existing && options.force !== true) {
5887
- resolve();
5888
- return;
5889
- }
5890
- existing?.remove?.();
5891
- const script = document.createElement('script');
5892
- const timeout = window.setTimeout(() => {
5893
- cleanup();
5894
- reject(new Error(`Timed out loading ${src}`));
5895
- }, 8000);
5896
- function cleanup() {
5897
- window.clearTimeout(timeout);
5898
- script.onload = null;
5899
- script.onerror = null;
5900
- script.remove();
5901
- }
5902
- script.id = id;
5903
- const runtimePrefix = String(root.__CODEX_OVERLEAF_RUNTIME_PREFIX__ || '');
5904
- script.src = chrome.runtime.getURL(runtimePrefix + src);
5905
- script.onload = () => {
5906
- cleanup();
5907
- resolve();
5908
- };
5909
- script.onerror = () => {
5910
- cleanup();
5911
- reject(new Error(`Failed to load ${src}`));
5912
- };
5913
- (document.head || document.documentElement).append(script);
5914
- });
5422
+ async function loadStoredState() {
5423
+ const projectId = getCurrentProjectId();
5424
+ const persistenceCoordinator = getScopedPersistenceCoordinator();
5425
+ const hydrationView = projectId && persistenceCoordinator
5426
+ ? await persistenceCoordinator.beginHydration(projectId)
5427
+ : null;
5428
+ const hydrationAccountScopeId = hydrationView?.accountScopeId || cachedAccountScopeId || '';
5429
+ const loaded = await loadStoredStateForProject(hydrationAccountScopeId);
5430
+ if (
5431
+ hydrationView
5432
+ && persistenceCoordinator
5433
+ && !persistenceCoordinator.isCurrent(hydrationView)
5434
+ ) {
5435
+ throw Object.assign(new Error('Scoped hydration was superseded.'), {
5436
+ code: 'stale_hydration'
5437
+ });
5438
+ }
5439
+ return loaded;
5915
5440
  }
5916
5441
 
5917
- async function initializePageBridgeCapability() {
5918
- const result = await sendPageBridgeRequest('initializeCapability', {}, {
5919
- timeoutMs: 8000
5920
- });
5921
- if (!result?.ok) {
5922
- throw new Error(result?.error || result?.reason || 'Page bridge capability initialization failed');
5442
+ function buildScopedFallbackStorageKey(baseKey, accountScopeId) {
5443
+ const account = typeof accountScopeId === 'string' ? accountScopeId.trim() : '';
5444
+ if (!account) {
5445
+ throw Object.assign(new Error('Scoped fallback storage requires an account identity.'), {
5446
+ code: 'account_scope_unavailable'
5447
+ });
5923
5448
  }
5449
+ return `${baseKey}:account:${encodeURIComponent(account)}`;
5924
5450
  }
5925
5451
 
5926
- function createPageBridgeCapability() {
5927
- if (crypto?.randomUUID) {
5928
- return crypto.randomUUID();
5929
- }
5930
- const bytes = new Uint8Array(24);
5931
- crypto.getRandomValues(bytes);
5932
- return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
5452
+ function isScopedPersistenceIdentityFailure(error) {
5453
+ return [
5454
+ 'account_scope_unavailable',
5455
+ 'scoped_claim_unavailable',
5456
+ 'stale_hydration',
5457
+ 'stale_view'
5458
+ ].includes(error?.code);
5933
5459
  }
5934
5460
 
5935
- async function loadStoredState() {
5461
+ async function loadStoredStateForProject(accountScopeId = cachedAccountScopeId || '') {
5936
5462
  try {
5937
- const StorageDb = window.CodexOverleafStorageDb;
5938
- const Migration = window.CodexOverleafStorageMigration;
5463
+ const StorageDb = Modules.StorageDb;
5464
+ const Migration = Modules.StorageMigration;
5939
5465
  const projectId = getCurrentProjectId();
5940
5466
  const legacyKey = storageKey;
5941
5467
 
5942
- const { prefs, sessions, activeSessionId } = await Migration.runMigrationIfNeeded(projectId, legacyKey);
5468
+ const { prefs, sessions, activeSessionId } = await Migration.runMigrationIfNeeded(
5469
+ projectId,
5470
+ legacyKey,
5471
+ accountScopeId
5472
+ );
5943
5473
 
5944
5474
  return {
5945
5475
  model: prefs.model || '', providerId: prefs.providerId || 'builtin',
@@ -5958,6 +5488,7 @@
5958
5488
  panelWidth: prefs.panelWidth || PANEL_DEFAULT_WIDTH,
5959
5489
  sessions: sessions.map(session => ({
5960
5490
  id: session.id,
5491
+ accountScopeId: session.accountScopeId || accountScopeId,
5961
5492
  title: session.title || '',
5962
5493
  titleSource: session.titleSource || 'auto',
5963
5494
  focusFiles: session.focusFiles || [],
@@ -5968,7 +5499,7 @@
5968
5499
  history: Array.isArray(session.history) ? session.history : [],
5969
5500
  task: typeof session.task === 'string' ? session.task : '',
5970
5501
  pendingInputs: Array.isArray(session.pendingInputs) ? session.pendingInputs : [],
5971
- mode: session.mode || prefs.mode || 'confirm', providerId: session.providerId || 'builtin',
5502
+ mode: session.mode || prefs.mode || 'ask', providerId: session.providerId || 'builtin',
5972
5503
  model: session.model || prefs.model || 'gpt-5.4',
5973
5504
  reasoningEffort: session.reasoningEffort || prefs.reasoningEffort || 'high',
5974
5505
  speedTier: session.speedTier || prefs.speedTier || 'standard',
@@ -5980,13 +5511,16 @@
5980
5511
  runs: []
5981
5512
  };
5982
5513
  } catch (error) {
5983
- // Fallback to legacy loading if IndexedDB fails
5984
- const keys = storageKey === LEGACY_STORAGE_KEY
5985
- ? [LEGACY_STORAGE_KEY]
5986
- : [storageKey, LEGACY_STORAGE_KEY];
5987
- const stored = await chrome.storage.local.get(keys);
5988
- const fallback = stored[storageKey] || stored[LEGACY_STORAGE_KEY] || {};
5989
- if (fallback?.__codexOverleafCompactFallback === true) {
5514
+ if (!accountScopeId || isScopedPersistenceIdentityFailure(error)) {
5515
+ throw error;
5516
+ }
5517
+ const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5518
+ const stored = await chrome.storage.local.get([fallbackKey]);
5519
+ const fallback = stored[fallbackKey] || {};
5520
+ if (
5521
+ fallback?.__codexOverleafCompactFallback === true
5522
+ && fallback.__codexOverleafFallbackAccountScopeId === accountScopeId
5523
+ ) {
5990
5524
  return {
5991
5525
  mode: fallback.mode || '', providerId: fallback.providerId || 'builtin',
5992
5526
  model: fallback.model || '',
@@ -6007,7 +5541,7 @@
6007
5541
  runs: []
6008
5542
  };
6009
5543
  }
6010
- return fallback;
5544
+ throw error;
6011
5545
  }
6012
5546
  }
6013
5547
 
@@ -6027,8 +5561,8 @@
6027
5561
 
6028
5562
  async function saveState(options) {
6029
5563
  try {
6030
- const StorageDb = window.CodexOverleafStorageDb;
6031
- const Migration = window.CodexOverleafStorageMigration;
5564
+ const StorageDb = Modules.StorageDb;
5565
+ const Migration = Modules.StorageMigration;
6032
5566
  // Welcome-panel + write-guard:
6033
5567
  // post-navigation persistence gate. The normal URL-derived projectId
6034
5568
  // belongs to the route the user is *currently looking at*. When a run
@@ -6074,86 +5608,85 @@
6074
5608
  compactState.experimentalOtByProject = state.experimentalOtByProject;
6075
5609
  compactState.customInstructionsByProject = state.customInstructionsByProject;
6076
5610
  compactState.governanceRulesByProject = state.governanceRulesByProject;
6077
-
6078
- // Save lightweight prefs to chrome.storage.local
6079
- const latestPrefs = typeof Migration.loadPrefs === 'function'
6080
- ? await Migration.loadPrefs()
6081
- : {};
6082
- const prefsFromState = StorageDb.extractLightweightPrefs(compactState, projectId);
6083
- const prefs = {
6084
- ...(latestPrefs && typeof latestPrefs === 'object' ? latestPrefs : {}),
6085
- ...prefsFromState
6086
- };
6087
- prefs.activeSessionByProject = StorageDb.buildActiveSessionByProject(
6088
- latestPrefs?.activeSessionByProject || {},
5611
+ const stateSnapshot = typeof structuredClone === 'function'
5612
+ ? structuredClone(state)
5613
+ : JSON.parse(JSON.stringify(state));
5614
+ const compactStateSnapshot = typeof structuredClone === 'function'
5615
+ ? structuredClone(compactState)
5616
+ : JSON.parse(JSON.stringify(compactState));
5617
+
5618
+ const persistScopedState = persistenceContext => Modules.ScopedPersistenceCoordinator.persistPanelState({
5619
+ state: stateSnapshot,
5620
+ compactState: compactStateSnapshot,
6089
5621
  projectId,
6090
- compactState.activeSessionId || state.activeSessionId || ''
6091
- );
6092
- prefs.experimentalOtByProject = {
6093
- ...(latestPrefs?.experimentalOtByProject || {}),
6094
- [projectId]: normalizeExperimentalOtByProject(state?.experimentalOtByProject)[projectId] === true
6095
- };
6096
- const normalizedGovernanceByProject = typeof normalizeGovernanceRulesByProject === 'function'
6097
- ? normalizeGovernanceRulesByProject(state?.governanceRulesByProject)
6098
- : (state?.governanceRulesByProject || {});
6099
- prefs.governanceRulesByProject = {
6100
- ...(latestPrefs?.governanceRulesByProject || {}),
6101
- ...normalizedGovernanceByProject
6102
- };
6103
- const normalizedCustomProject = normalizeCustomInstructionsByProject({ [projectId]: '' });
6104
- const normalizedCustomProjectId = Object.keys(normalizedCustomProject)[0] || '';
6105
- const currentCustomInstructions = normalizeCustomInstructionsByProject(state?.customInstructionsByProject);
6106
- prefs.customInstructionsByProject = {
6107
- ...(latestPrefs?.customInstructionsByProject || {})
6108
- };
6109
- if (normalizedCustomProjectId) {
6110
- prefs.customInstructionsByProject[normalizedCustomProjectId] =
6111
- Object.prototype.hasOwnProperty.call(currentCustomInstructions, normalizedCustomProjectId)
6112
- ? currentCustomInstructions[normalizedCustomProjectId]
6113
- : '';
6114
- }
6115
- await Migration.savePrefs(prefs);
6116
-
6117
- // Save all displayable session state to IndexedDB. The panel history lives here;
6118
- // chrome.storage.local only keeps small pointers/preferences.
6119
- const sessionRecords = (state.sessions || []).map(session => (
6120
- StorageDb.buildSessionRecord({
6121
- ...session,
6122
- projectId,
6123
- title: session.title || '',
6124
- titleSource: session.titleSource || 'auto',
6125
- codexThreadId: session.codexThreadId || '',
6126
- status: 'active',
6127
- focusFiles: Array.isArray(session.focusFiles) ? session.focusFiles : [],
6128
- createdAt: session.createdAt,
6129
- updatedAt: session.updatedAt
6130
- }, { preserveRunActionPayload: true })
6131
- ));
6132
- await window.CodexOverleafSessionPersistence.writeSessions({
6133
- Migration, StorageDb, projectId,
6134
5622
  deletedSessionIds: options?.deletedSessionIds,
6135
- sessionRecords
5623
+ Migration,
5624
+ StorageDb,
5625
+ SessionPersistence: Modules.SessionPersistence,
5626
+ normalizeExperimentalOtByProject,
5627
+ normalizeGovernanceRulesByProject,
5628
+ normalizeCustomInstructionsByProject,
5629
+ persistenceContext
6136
5630
  });
5631
+
5632
+ const persistenceCoordinator = getScopedPersistenceCoordinator();
5633
+ if (!persistenceCoordinator) {
5634
+ throw Object.assign(new Error('Scoped persistence coordinator is unavailable.'), {
5635
+ code: 'scoped_persistence_unavailable'
5636
+ });
5637
+ } else {
5638
+ const committed = await persistenceCoordinator.commit(
5639
+ projectId,
5640
+ {
5641
+ detached: Boolean(projectIdOverride),
5642
+ rebase: true,
5643
+ queueMutation: options?.queueMutation,
5644
+ queueMutations: options?.queueMutations,
5645
+ sessionTombstones: options?.deletedSessionIds
5646
+ },
5647
+ persistScopedState
5648
+ );
5649
+ if (!committed.ok && (
5650
+ options?.queueMutation
5651
+ || options?.queueMutations?.length
5652
+ || committed.reason !== 'stale_view'
5653
+ )) {
5654
+ throw Object.assign(new Error(`Scoped persistence failed: ${committed.reason}`), {
5655
+ code: committed.reason
5656
+ });
5657
+ }
5658
+ }
6137
5659
  } catch (error) {
6138
- // Fallback: try legacy save
5660
+ if (
5661
+ error?.code === 'account_scope_unavailable' &&
5662
+ !options?.queueMutation &&
5663
+ !(Array.isArray(options?.queueMutations) && options.queueMutations.length)
5664
+ ) {
5665
+ return;
5666
+ }
5667
+ if (options?.queueMutation || options?.queueMutations?.length) {
5668
+ throw error;
5669
+ }
5670
+ let fallbackError = null;
6139
5671
  try {
6140
- await chrome.storage.local.set({ [storageKey]: prepareCompactFallbackState(state) });
6141
- } catch (fallbackError) {
6142
- // Structured FailureReason §9.8: persistence raised a quota error.
6143
- // Surface a `storage_quota_exceeded` failure (warning, retryable)
6144
- // alongside the legacy toast notice so downstream renderers and tests
6145
- // can detect the quota-specific class. The structured emit fires for
6146
- // both the original `error` (primary IndexedDB write) and the
6147
- // `fallbackError` (chrome.storage.local fallback).
6148
- const quotaHit = isStorageQuotaError(error) || isStorageQuotaError(fallbackError);
6149
- if (quotaHit) {
6150
- emitStorageQuotaFailure(error, fallbackError);
6151
- }
6152
- if (typeof appendStorageNoticeOnce === 'function') {
6153
- appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
6154
- } else {
6155
- throw fallbackError;
6156
- }
5672
+ const accountScopeId = cachedAccountScopeId || '';
5673
+ const fallbackKey = buildScopedFallbackStorageKey(storageKey, accountScopeId);
5674
+ await chrome.storage.local.set({
5675
+ [fallbackKey]: {
5676
+ ...prepareCompactFallbackState(state),
5677
+ __codexOverleafFallbackAccountScopeId: accountScopeId
5678
+ }
5679
+ });
5680
+ } catch (caughtFallbackError) {
5681
+ fallbackError = caughtFallbackError;
5682
+ }
5683
+ if (isStorageQuotaError(error)) {
5684
+ emitStorageQuotaFailure(error, fallbackError);
5685
+ }
5686
+ if (typeof appendStorageNoticeOnce === 'function') {
5687
+ appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
5688
+ } else {
5689
+ throw error;
6157
5690
  }
6158
5691
  }
6159
5692
  }
@@ -6216,7 +5749,8 @@
6216
5749
  showPluginToast(text, { status: 'warning', sticky: true });
6217
5750
  }
6218
5751
 
6219
- function saveStateSoon(delayMs = 120) {
5752
+ function saveStateSoon(delayMs = 120, persistenceOptions = null) {
5753
+ pendingSaveStateOptions = mergeSaveStateOptions(pendingSaveStateOptions, persistenceOptions);
6220
5754
  // If a saveState() is already in flight, do NOT start a parallel one.
6221
5755
  // Mark the trailing flag so the in-flight save's completion callback
6222
5756
  // schedules another one — that way the final disk snapshot reflects
@@ -6238,9 +5772,15 @@
6238
5772
  }
6239
5773
 
6240
5774
  function runQueuedSaveState() {
5775
+ if (saveStateInFlightPromise) {
5776
+ saveStateRunAfterFlight = true;
5777
+ return saveStateInFlightPromise;
5778
+ }
6241
5779
  saveStateInFlight = true;
6242
5780
  saveStateRunAfterFlight = false;
6243
- saveState()
5781
+ const persistenceOptions = pendingSaveStateOptions;
5782
+ pendingSaveStateOptions = null;
5783
+ const runPromise = saveState(persistenceOptions)
6244
5784
  .catch(error => {
6245
5785
  if (isStorageQuotaError(error)) {
6246
5786
  // Structured FailureReason §9.8: timer-driven saveState raised a
@@ -6252,6 +5792,9 @@
6252
5792
  appendPlainLog(tx(`Failed to save session state: ${formatStateSaveError(error)}`, `保存会话状态失败:${formatStateSaveError(error)}`));
6253
5793
  })
6254
5794
  .finally(() => {
5795
+ if (saveStateInFlightPromise === runPromise) {
5796
+ saveStateInFlightPromise = null;
5797
+ }
6255
5798
  saveStateInFlight = false;
6256
5799
  if (saveStateRunAfterFlight) {
6257
5800
  // A saveStateSoon() landed during the in-flight phase. Flush it
@@ -6260,6 +5803,52 @@
6260
5803
  saveStateSoon(0);
6261
5804
  }
6262
5805
  });
5806
+ saveStateInFlightPromise = runPromise;
5807
+ return runPromise;
5808
+ }
5809
+
5810
+ async function flushQueuedSaveState(persistenceOptions = null) {
5811
+ pendingSaveStateOptions = mergeSaveStateOptions(pendingSaveStateOptions, persistenceOptions);
5812
+ let persistLatestSnapshot = true;
5813
+ while (persistLatestSnapshot) {
5814
+ if (saveStateTimer) {
5815
+ clearTimeout(saveStateTimer);
5816
+ saveStateTimer = null;
5817
+ }
5818
+ if (saveStateInFlightPromise) {
5819
+ await saveStateInFlightPromise;
5820
+ if (saveStateTimer) {
5821
+ clearTimeout(saveStateTimer);
5822
+ saveStateTimer = null;
5823
+ }
5824
+ }
5825
+
5826
+ saveStateRunAfterFlight = false;
5827
+ await runQueuedSaveState();
5828
+ persistLatestSnapshot = Boolean(
5829
+ saveStateTimer
5830
+ || saveStateRunAfterFlight
5831
+ || pendingSaveStateOptions
5832
+ );
5833
+ }
5834
+ }
5835
+
5836
+ function mergeSaveStateOptions(current, incoming) {
5837
+ if (!incoming) return current;
5838
+ const queueMutations = [
5839
+ ...(current?.queueMutations || (current?.queueMutation ? [current.queueMutation] : [])),
5840
+ ...(incoming?.queueMutations || (incoming?.queueMutation ? [incoming.queueMutation] : []))
5841
+ ];
5842
+ return {
5843
+ ...(current || {}),
5844
+ ...incoming,
5845
+ queueMutation: undefined,
5846
+ queueMutations,
5847
+ deletedSessionIds: Array.from(new Set([
5848
+ ...(current?.deletedSessionIds || []),
5849
+ ...(incoming?.deletedSessionIds || [])
5850
+ ]))
5851
+ };
6263
5852
  }
6264
5853
 
6265
5854
  function scheduleRunStateSave(kind) {
@@ -6313,7 +5902,7 @@
6313
5902
  }
6314
5903
 
6315
5904
  async function selectMode(mode) {
6316
- if (!['ask', 'confirm', 'auto'].includes(mode)) {
5905
+ if (!['ask', 'auto'].includes(mode)) {
6317
5906
  return;
6318
5907
  }
6319
5908
  const modeSelect = panel?.querySelector('[data-mode]');
@@ -6511,7 +6100,15 @@
6511
6100
  renderPendingInputs();
6512
6101
  }
6513
6102
 
6514
- function startRunView({ task, mode, model, reasoningEffort, speedTier, queueItemId = '' }) {
6103
+ function startRunView({
6104
+ task,
6105
+ mode,
6106
+ model,
6107
+ reasoningEffort,
6108
+ speedTier,
6109
+ queueItemId = '',
6110
+ executionSnapshot = null
6111
+ }) {
6515
6112
  let attachments = [];
6516
6113
  if (Array.isArray(arguments[0]?.attachments)) {
6517
6114
  attachments = arguments[0].attachments;
@@ -6521,7 +6118,8 @@
6521
6118
  id: createRunId(),
6522
6119
  task,
6523
6120
  mode,
6524
- ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
6121
+ ...providerSettingsCoordinator.getProviderSnapshot(executionSnapshot?.providerId || state?.providerId),
6122
+ providerRevision: executionSnapshot?.providerRevision || '',
6525
6123
  model,
6526
6124
  reasoningEffort,
6527
6125
  speedTier,
@@ -6539,6 +6137,9 @@
6539
6137
  nativeRequestId: '',
6540
6138
  codexTurnId: '',
6541
6139
  nativeEventSeq: 0,
6140
+ executionSnapshot: executionSnapshot
6141
+ ? RunExecutionSnapshot.cloneSnapshot(executionSnapshot)
6142
+ : undefined,
6542
6143
  // Welcome-panel + write-guard:
6543
6144
  // Immutable per-run capture of the project this run was submitted
6544
6145
  // against. Every writeback / accept / undo dispatch attaches this id
@@ -6546,7 +6147,8 @@
6546
6147
  // is still acting on the same project before mutating the editor. No
6547
6148
  // language-level enforcement — the field is treated as immutable by
6548
6149
  // convention and never reassigned anywhere downstream.
6549
- runProjectId: getCurrentProjectId() || ''
6150
+ runProjectId: getCurrentProjectId() || '',
6151
+ runAccountScopeId: cachedAccountScopeId || ''
6550
6152
  };
6551
6153
  const active = getActiveSession(state);
6552
6154
  const titlePatch = active?.titleSource !== 'manual'
@@ -6576,13 +6178,19 @@
6576
6178
  // Mirror the immutable runProjectId on the view so writeback /
6577
6179
  // accept / undo dispatches don't need to re-look up the record.
6578
6180
  runProjectId: record.runProjectId,
6181
+ runAccountScopeId: record.runAccountScopeId,
6182
+ executionSnapshot: record.executionSnapshot,
6579
6183
  root,
6580
6184
  runProcess: root.querySelector('[data-run-process]'),
6581
6185
  processLabel: root.querySelector('[data-run-process-summary]'),
6582
6186
  events: root.querySelector('[data-run-events]'),
6583
6187
  report: root.querySelector('[data-run-report]'),
6584
6188
  status: root.querySelector('[data-run-status]'),
6585
- projectFiles: captureProjectReferenceFiles(arguments[0]?.project),
6189
+ // A run starts before warm-mirror resolution has produced its lightweight
6190
+ // project snapshot. Seed references from the already-prefetched project
6191
+ // inventory so early stream events and fast Ask responses can still
6192
+ // resolve project-relative locations.
6193
+ projectFiles: getCurrentProjectReferenceFiles(),
6586
6194
  startedAt: Date.now(),
6587
6195
  queueItemId,
6588
6196
  nativeRequestId: '',
@@ -6590,7 +6198,20 @@
6590
6198
  };
6591
6199
  }
6592
6200
 
6593
- function finishRunView(text, status) {
6201
+ function touchSessionForTerminalRun(sessionId, finishedAt) {
6202
+ const session = Array.isArray(state.sessions)
6203
+ ? state.sessions.find(item => item?.id === sessionId)
6204
+ : null;
6205
+ if (!session) return '';
6206
+ const previousUpdatedAt = Date.parse(session.updatedAt || '') || 0;
6207
+ const requestedUpdatedAt = Date.parse(finishedAt || '') || Date.now();
6208
+ const updatedAt = new Date(Math.max(requestedUpdatedAt, previousUpdatedAt + 1)).toISOString();
6209
+ session.updatedAt = updatedAt;
6210
+ session.lastActivityAt = updatedAt;
6211
+ return updatedAt;
6212
+ }
6213
+
6214
+ async function finishRunView(text, status) {
6594
6215
  if (!currentRunView) {
6595
6216
  return;
6596
6217
  }
@@ -6603,6 +6224,7 @@
6603
6224
  record.status = status;
6604
6225
  record.statusText = statusText;
6605
6226
  record.finishedAt = new Date().toISOString();
6227
+ touchSessionForTerminalRun(currentRunView.sessionId, record.finishedAt);
6606
6228
  currentRunView.terminalStatus = status;
6607
6229
  runGuidanceController.settleView(currentRunView);
6608
6230
  // Welcome-panel + write-guard:
@@ -6616,7 +6238,11 @@
6616
6238
  const navigationDivergent = currentRunView.runProjectId
6617
6239
  && activeProjectId !== currentRunView.runProjectId;
6618
6240
  if (!navigationDivergent) {
6619
- saveStateSoon();
6241
+ // A terminal badge is a durability promise: once the user can see
6242
+ // Done/Failed/Cancelled, an immediate reload must hydrate that same
6243
+ // terminal state. Drain any older running snapshot and persist the
6244
+ // latest record before exposing the terminal UI.
6245
+ await flushQueuedSaveState().catch(() => {});
6620
6246
  }
6621
6247
  renderSessionList();
6622
6248
  }
@@ -6936,6 +6562,39 @@
6936
6562
 
6937
6563
 
6938
6564
 
6565
+ function getCurrentProjectMathSources() {
6566
+ const sources = [];
6567
+ const seen = new Set();
6568
+ let remainingChars = 256 * 1024;
6569
+ const add = item => {
6570
+ if (!item || typeof item !== 'object' || typeof item.content !== 'string') return;
6571
+ const path = normalizeReferencePathForRuntime(item.path || item.filePath || '');
6572
+ if (!path || seen.has(path) || hasUnsafeRuntimePathSegments(path)
6573
+ || !/\.(?:tex|sty|cls|ltx)$/i.test(path) || remainingChars <= 0) return;
6574
+ const content = item.content.slice(0, Math.min(64 * 1024, remainingChars));
6575
+ remainingChars -= content.length;
6576
+ seen.add(path);
6577
+ sources.push({ path, content });
6578
+ };
6579
+ for (const collection of [
6580
+ currentRunView?.projectFiles,
6581
+ currentRunView?.files,
6582
+ currentRunView?.project?.files,
6583
+ currentRunView?.snapshot?.files,
6584
+ currentRunView?.projectSnapshot?.files,
6585
+ currentRunView?.contextFiles,
6586
+ state?.projectFiles,
6587
+ state?.files,
6588
+ state?.project?.files,
6589
+ state?.snapshot?.files,
6590
+ state?.projectSnapshot?.files,
6591
+ state?.contextFiles
6592
+ ]) {
6593
+ if (Array.isArray(collection)) collection.forEach(add);
6594
+ }
6595
+ return sources;
6596
+ }
6597
+
6939
6598
  function getCurrentProjectReferenceFiles() {
6940
6599
  const files = [];
6941
6600
  const seen = new Set();
@@ -6986,7 +6645,14 @@
6986
6645
  }
6987
6646
  };
6988
6647
 
6648
+ const activeSession = typeof getActiveSession === 'function' ? getActiveSession(state) : null;
6989
6649
  addFiles(currentRunView?.projectFiles);
6650
+ addFiles(activeSession?.projectReferenceFiles);
6651
+ // ContextTray owns the exact, background-prefetched project file list.
6652
+ // Warm-mirror runs intentionally carry only the active/focused overlay, so
6653
+ // omitting this inventory makes valid references to every other project
6654
+ // file render as plain text.
6655
+ addFiles(contextTrayController?.getContextProject?.()?.files);
6990
6656
  addFiles(currentRunView?.files);
6991
6657
  addFiles(currentRunView?.project?.files);
6992
6658
  addFiles(currentRunView?.snapshot?.files);
@@ -7000,14 +6666,26 @@
7000
6666
  addFiles(state?.contextFiles);
7001
6667
  addTextPaths(state?.focusFiles);
7002
6668
  addTextPaths(state?.session?.focusFiles);
7003
- const activeSession = typeof getActiveSession === 'function' ? getActiveSession(state) : null;
7004
6669
  addTextPaths(activeSession?.focusFiles);
7005
6670
  for (const session of Array.isArray(state?.sessions) ? state.sessions : []) {
6671
+ addFiles(session?.projectReferenceFiles);
7006
6672
  addTextPaths(session?.focusFiles);
7007
6673
  }
7008
6674
  return files;
7009
6675
  }
7010
6676
 
6677
+ function persistCurrentProjectReferenceFiles(project) {
6678
+ const projectFiles = captureProjectReferenceFiles(project);
6679
+ if (currentRunView) {
6680
+ currentRunView.projectFiles = projectFiles;
6681
+ }
6682
+ if (getActiveSession(state)) {
6683
+ state = updateActiveSession(state, { projectReferenceFiles: projectFiles });
6684
+ saveStateSoon();
6685
+ }
6686
+ return projectFiles;
6687
+ }
6688
+
7011
6689
  function captureProjectReferenceFiles(project) {
7012
6690
  const result = [];
7013
6691
  const seen = new Set();
@@ -7032,31 +6710,11 @@
7032
6710
 
7033
6711
 
7034
6712
  function isUndoResultEffectivelyApplied(run, result) {
7035
- if (!result?.skipped?.length) {
7036
- return true;
7037
- }
7038
- const expectedByPath = new Map((run?.undoExpectedFiles || [])
7039
- .filter(file => file?.path && typeof file.content === 'string')
7040
- .map(file => [file.path, file.content]));
7041
- if (!expectedByPath.size) {
7042
- return false;
7043
- }
7044
- const undoRestore = buildNoTraceUndoRestore(run);
7045
- const editPaths = Array.from(new Set((undoRestore.operations || [])
7046
- .filter(operation => (
7047
- operation?.type === 'edit'
7048
- && operation.path
7049
- && expectedByPath.has(operation.path)
7050
- ))
7051
- .map(operation => operation.path)));
7052
- if (!editPaths.length) {
7053
- return false;
7054
- }
7055
- return editPaths.every(path => (result.applied || []).some(item => (
7056
- item?.operation?.type === 'edit'
7057
- && item.operation.path === path
7058
- && item?.result?.verifiedContent === expectedByPath.get(path)
7059
- )));
6713
+ return WritebackSettlement.isUndoResultEffectivelyApplied(
6714
+ run,
6715
+ result,
6716
+ buildNoTraceUndoRestore(run).operations
6717
+ );
7060
6718
  }
7061
6719
 
7062
6720
  async function undoRun(runId) {
@@ -7166,7 +6824,11 @@
7166
6824
  // A subset undo never closes the ledger: unselected files can still
7167
6825
  // be undone later, so the run stays 'partial' and the button usable.
7168
6826
  const fullSelection = selectedOperations.length === undoOperations.length;
7169
- setRunUndoStatus(runId, undoApplied && fullSelection ? 'applied' : 'partial');
6827
+ applyLegacyUndoSettlement(
6828
+ runId,
6829
+ undoApplied && fullSelection ? 'applied' : 'partial',
6830
+ result
6831
+ );
7170
6832
  }
7171
6833
 
7172
6834
  async function undoRunTrackedChanges(runId, run) {
@@ -7239,91 +6901,13 @@
7239
6901
  }
7240
6902
  });
7241
6903
  if (lifecycleReject) {
7242
- // Structured FailureReason §9.5: post-undo proof step. If the page op
7243
- // reported ok-ish (no skipped items) but the per-path verifiedContent
7244
- // does not match this run's expected pre-write content, synthesize an
7245
- // `undo_not_verified` failure (warning, retryable, needs_review,
7246
- // changedDocument:true) and attach it to the result so the §7
7247
- // settlement matrix routes the run to `needs_review`.
7248
- attachUndoNotVerifiedFailure(run, result);
7249
- // §7 settlement matrix: only land in terminal `rejected` when post-action
7250
- // proof is sufficient. If any per-op failure marks `needs_review` (or
7251
- // matches a known unverified code), settle as `needs_review` so both
7252
- // Accept and Undo stay actionable and the user can reconcile.
7253
- applyRejectSettlement(runId, result);
7254
- return;
7255
- }
7256
- setRunUndoStatus(runId, result.skipped?.length ? 'partial' : 'applied');
7257
- }
7258
-
7259
- // Post-undo proof step (§9.5). Called only on lifecycle reject paths after
7260
- // the page bridge has returned. We only synthesize an `undo_not_verified`
7261
- // failure when:
7262
- // - The page op did not already emit a needs_review-class failure (the
7263
- // settlement matrix would have caught it).
7264
- // - The reject result has no skipped items (otherwise the existing
7265
- // skipped failures drive settlement).
7266
- // - At least one expected pre-run path has no matching `applied` entry
7267
- // whose `verifiedContent` equals the expected content. (Note: we do
7268
- // NOT delegate to `isUndoResultEffectivelyApplied` because that helper
7269
- // short-circuits to true on `skipped.length === 0` — the §9.5 contract
7270
- // requires real per-path verifiedContent proof.)
7271
- // The synthesized failure is appended to `result.skipped` as a synthetic
7272
- // proof entry so `collectFailuresFromResult` picks it up.
7273
- function attachUndoNotVerifiedFailure(run, result) {
7274
- if (!result || typeof result !== 'object') return;
7275
- if (result.ok === false) return;
7276
- if (Array.isArray(result.skipped) && result.skipped.length > 0) return;
7277
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7278
- if (!expectedFiles.length) return;
7279
- if (isUndoVerifiedContentMatching(run, result)) return;
7280
- const firstPath = expectedFiles.find(entry => entry && typeof entry.path === 'string')?.path || '';
7281
- const failure = buildContentFailure('undo_not_verified', { path: firstPath, type: 'undo' }, {
7282
- changedDocument: true,
7283
- terminalState: 'needs_review',
7284
- evidence: {
7285
- undoApplied: true,
7286
- verified: false,
7287
- expectedFileCount: expectedFiles.length
7288
- }
7289
- });
7290
- const synthetic = {
7291
- operation: { path: firstPath, type: 'undo' },
7292
- result: {
7293
- ok: false,
7294
- code: 'undo_not_verified',
7295
- reason: failure.userMessage,
7296
- failure
7297
- }
7298
- };
7299
- if (!Array.isArray(result.skipped)) {
7300
- result.skipped = [];
7301
- }
7302
- result.skipped.push(synthetic);
7303
- result.ok = false;
7304
- }
7305
-
7306
- // Per-path verifiedContent match for the §9.5 proof step. Returns true
7307
- // only when every expected pre-run path has a matching `applied` entry
7308
- // whose `result.verifiedContent` equals the expected content. Empty
7309
- // expected files map → true (nothing to prove).
7310
- function isUndoVerifiedContentMatching(run, result) {
7311
- const expectedByPath = new Map((run?.undoExpectedFiles || [])
7312
- .filter(file => file && typeof file.path === 'string' && typeof file.content === 'string')
7313
- .map(file => [file.path, file.content]));
7314
- if (!expectedByPath.size) return true;
7315
- const applied = Array.isArray(result.applied) ? result.applied : [];
7316
- for (const [path, expected] of expectedByPath.entries()) {
7317
- const matched = applied.some(entry => {
7318
- if (!entry || !entry.operation) return false;
7319
- if (entry.operation.path !== path) return false;
7320
- if (entry.operation.type !== 'edit') return false;
7321
- if (entry.result && entry.result.ok === false) return false;
7322
- return entry.result && entry.result.verifiedContent === expected;
6904
+ WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6905
+ buildFailure: buildContentFailure
7323
6906
  });
7324
- if (!matched) return false;
6907
+ applyTrackedChangeSettlement(runId, 'reject', result);
6908
+ return;
7325
6909
  }
7326
- return true;
6910
+ applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
7327
6911
  }
7328
6912
 
7329
6913
  async function acceptRun(runId) {
@@ -7345,8 +6929,6 @@
7345
6929
  return;
7346
6930
  }
7347
6931
 
7348
- // UI-local in-flight lock: disables the acting button with progress. Never
7349
- // written to trackedChangeStatus, never persisted.
7350
6932
  trackedChangeInFlight.set(runId, 'accept');
7351
6933
  refreshRunCardControls(runId);
7352
6934
  appendRunRecordEvent(runId, {
@@ -7361,25 +6943,12 @@
7361
6943
  trackedChanges: run.undoTrackedChanges || [],
7362
6944
  expectedFiles: run.undoExpectedFiles || [],
7363
6945
  postFiles: buildTrackedUndoPostFiles(run),
7364
- // The run's own forward writeback operations. The Accept replay
7365
- // re-applies these exact patches so it writes only the changed
7366
- // fragments, never a whole-file overwrite.
7367
6946
  appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
7368
- // Welcome-panel + write-guard:
7369
- // bind the accept replay to the run's original project. If the user
7370
- // has navigated to a different project the page-side guard refuses
7371
- // with `aborted_project_changed` before any mutation.
7372
6947
  runProjectId: getRunProjectIdForWriteback(run)
7373
6948
  });
7374
6949
  } finally {
7375
6950
  trackedChangeInFlight.delete(runId);
7376
6951
  }
7377
- // Surface every page-layer step as its own run-card event row so the user
7378
- // can copy-paste the full Accept trace if the sticky-Editing reconfirm
7379
- // ever still cannot keep the replay untracked. Each entry is
7380
- // self-contained — the diagnostic info inline carries the reviewing
7381
- // detector verdict, the strict gate booleans, and per-op
7382
- // re-toggle/verify outcomes so no extra page-state query is needed.
7383
6952
  appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
7384
6953
  appendRunRecordEvent(runId, {
7385
6954
  title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
@@ -7396,10 +6965,6 @@
7396
6965
  }))
7397
6966
  }
7398
6967
  });
7399
- // Accept All is editor-undo + untracked replay. If the editor-undo could not
7400
- // reach the pre-write state (content drifted), the page layer bails without
7401
- // re-writing and returns not-ok. The run then stays `pending` so the user
7402
- // can retry — it does NOT go to a decisive terminal status.
7403
6968
  if (result.ok === false) {
7404
6969
  appendRunRecordEvent(runId, {
7405
6970
  title: tr('runAcceptTrackedFailed'),
@@ -7408,121 +6973,22 @@
7408
6973
  [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
7409
6974
  }
7410
6975
  });
7411
- refreshRunCardControls(runId);
7412
- return;
6976
+ if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
6977
+ refreshRunCardControls(runId);
6978
+ }
7413
6979
  }
7414
- // Structured FailureReason §9.6: post-accept proof step. If the page op
7415
- // returned ok but cannot prove a clean post-accept state (no skipped
7416
- // items, no remaining tracked-change refs proven, and the post-files
7417
- // content of at least one path is missing or mismatched), synthesize an
7418
- // `accept_not_verified` failure (warning, retryable, needs_review,
7419
- // changedDocument:true) and attach it so the §7 settlement matrix routes
7420
- // the run to `needs_review` rather than terminal `accepted`.
7421
- attachAcceptNotVerifiedFailure(run, result);
7422
- // §7 settlement matrix: only land in terminal `accepted` when post-action
7423
- // proof is sufficient. If any per-op failure marks `needs_review` (or
7424
- // matches a known unverified code), settle as `needs_review` so both
7425
- // Accept and Undo stay actionable and the user can reconcile.
7426
- applyAcceptSettlement(runId, result);
7427
- }
7428
-
7429
- // Fail-closed when the run record carries no runProjectId of its own.
7430
- //
7431
- // Older persisted runs (pre-v1.3.8) and restored-from-history runs may have
7432
- // an empty runProjectId. Returning the editor's current projectId as a
7433
- // fallback would let the page-side write-guard pass when the user happens
7434
- // to be in the project they originally ran the task on — but the run
7435
- // itself has NO authoritative binding, so we can't actually prove it.
7436
- // Returning '' triggers the runProjectId-missing branch of writeGuard /
7437
- // checkWritebackRunProjectId, which surfaces editor_project_id_unavailable
7438
- // with the canonical 'Refresh Overleaf and retry' next-step text.
6980
+ WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
6981
+ buildFailure: buildContentFailure
6982
+ });
6983
+ applyTrackedChangeSettlement(runId, 'accept', result);
6984
+ }
6985
+
7439
6986
  function getRunProjectIdForWriteback(run) {
7440
6987
  return typeof run?.runProjectId === 'string' && run.runProjectId
7441
6988
  ? run.runProjectId
7442
6989
  : '';
7443
6990
  }
7444
6991
 
7445
- // Post-accept proof step (§9.6). Called only on lifecycle accept paths
7446
- // after the page bridge has returned. We only synthesize an
7447
- // `accept_not_verified` failure when:
7448
- // - The page op did not already emit a needs_review-class failure (the
7449
- // settlement matrix would have caught it).
7450
- // - The accept result has no skipped items (otherwise the existing
7451
- // skipped failures drive settlement).
7452
- // - The page bridge did not confirm clean post-accept state — either no
7453
- // `verified` flag, or the per-path applied items do not show the
7454
- // trackedChange was confirmed cleared.
7455
- // The synthesized failure is appended to `result.skipped` as a synthetic
7456
- // proof entry so `collectFailuresFromResult` picks it up.
7457
- function attachAcceptNotVerifiedFailure(run, result) {
7458
- if (!result || typeof result !== 'object') return;
7459
- if (result.ok === false) return;
7460
- if (Array.isArray(result.skipped) && result.skipped.length > 0) return;
7461
- if (isAcceptResultEffectivelyVerified(run, result)) return;
7462
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7463
- const firstPath = expectedFiles.find(entry => entry && typeof entry.path === 'string')?.path
7464
- || (run?.undoTrackedChanges || []).find(change => change && typeof change.path === 'string')?.path
7465
- || '';
7466
- const failure = buildContentFailure('accept_not_verified', { path: firstPath, type: 'accept' }, {
7467
- changedDocument: true,
7468
- terminalState: 'needs_review',
7469
- evidence: {
7470
- acceptApplied: true,
7471
- verified: false,
7472
- expectedFileCount: expectedFiles.length,
7473
- trackedChangeCount: Array.isArray(run?.undoTrackedChanges) ? run.undoTrackedChanges.length : 0
7474
- }
7475
- });
7476
- const synthetic = {
7477
- operation: { path: firstPath, type: 'accept' },
7478
- result: {
7479
- ok: false,
7480
- code: 'accept_not_verified',
7481
- reason: failure.userMessage,
7482
- failure
7483
- }
7484
- };
7485
- if (!Array.isArray(result.skipped)) {
7486
- result.skipped = [];
7487
- }
7488
- result.skipped.push(synthetic);
7489
- result.ok = false;
7490
- }
7491
-
7492
- // Mirrors `isUndoResultEffectivelyApplied` for the accept side. Returns
7493
- // true when the page-bridge result carries proof that the run's tracked
7494
- // changes are gone: each tracked-change ref has a matching `applied` entry
7495
- // whose `result.verifiedContent` equals the run's known post-write content
7496
- // for that path. Returns false when proof is missing or contradicted —
7497
- // §9.6 `accept_not_verified`. Falls back to true when there are no
7498
- // expected files (defensive: the page bridge already returned ok, and
7499
- // there is nothing to verify against).
7500
- function isAcceptResultEffectivelyVerified(run, result) {
7501
- if (!result || typeof result !== 'object') return true;
7502
- const expectedFiles = Array.isArray(run?.undoExpectedFiles) ? run.undoExpectedFiles : [];
7503
- if (!expectedFiles.length) return true;
7504
- if (result.verified === true) return true;
7505
- const trackedChanges = Array.isArray(run?.undoTrackedChanges) ? run.undoTrackedChanges : [];
7506
- if (!trackedChanges.length) return true;
7507
- const applied = Array.isArray(result.applied) ? result.applied : [];
7508
- if (!applied.length) return false;
7509
- // Every tracked-change ref must show up in `applied` with a result
7510
- // carrying ok:true. If any tracked change does not appear as applied,
7511
- // proof is missing.
7512
- return trackedChanges.every(change => {
7513
- const key = change && (change.key || change.id || change.label);
7514
- if (!key) return false;
7515
- return applied.some(entry => {
7516
- const ref = entry && entry.trackedChange;
7517
- if (!ref) return false;
7518
- const refKey = ref.key || ref.id || ref.label;
7519
- if (refKey !== key) return false;
7520
- if (entry.result && entry.result.ok === false) return false;
7521
- return true;
7522
- });
7523
- });
7524
- }
7525
-
7526
6992
  // Translates the page bridge's Accept All `diagnostics` array into one
7527
6993
  // run-card event row per step. Each entry is `{ step, info }`; the step
7528
6994
  // names map 1:1 to the runAcceptTrackedStep* i18n keys. We render `info`
@@ -7532,14 +6998,14 @@
7532
6998
  for (const entry of diagnostics || []) {
7533
6999
  const step = entry && entry.step;
7534
7000
  const info = (entry && entry.info) || {};
7535
- const titleKey = ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
7001
+ const titleKey = WritebackSettlement.ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
7536
7002
  if (!titleKey) {
7537
7003
  continue;
7538
7004
  }
7539
7005
  const title = titleKey === 'runAcceptTrackedStepReplayStart' || titleKey === 'runAcceptTrackedStepReplayDone'
7540
7006
  ? tr(titleKey, { path: info.path || tr('unknownFile') })
7541
7007
  : tr(titleKey);
7542
- const status = acceptDiagnosticStatus(step, info);
7008
+ const status = WritebackSettlement.acceptDiagnosticStatus(step, info);
7543
7009
  appendRunRecordEvent(runId, {
7544
7010
  title,
7545
7011
  kind: 'activity',
@@ -7549,289 +7015,28 @@
7549
7015
  }
7550
7016
  }
7551
7017
 
7552
- // Maps each diagnostic step name to its i18n title key. Kept out of the
7553
- // function body so any future step rename surfaces as a missing entry.
7554
- const ACCEPT_DIAGNOSTIC_TITLE_KEYS = {
7555
- editorUndo: 'runAcceptTrackedStepEditorUndo',
7556
- modeBefore: 'runAcceptTrackedStepModeBefore',
7557
- forceEditing: 'runAcceptTrackedStepForceEditing',
7558
- replayStart: 'runAcceptTrackedStepReplayStart',
7559
- replayDone: 'runAcceptTrackedStepReplayDone',
7560
- restoreReviewing: 'runAcceptTrackedStepRestoreReviewing'
7561
- };
7562
-
7563
- function acceptDiagnosticStatus(step, info) {
7564
- if (step === 'modeBefore' || step === 'replayStart') {
7565
- // Pre-state reads: never a failure, only context.
7566
- return 'info';
7567
- }
7568
- if (step === 'editorUndo' || step === 'forceEditing' || step === 'restoreReviewing') {
7569
- return info && info.ok === true ? 'info' : 'failed';
7570
- }
7571
- if (step === 'replayDone') {
7572
- return info && info.ok === true ? 'info' : 'failed';
7573
- }
7574
- return 'info';
7575
- }
7576
-
7577
- // Drives a tracked-change-lifecycle run to a decisive terminal status when
7578
- // post-action proof is sufficient. Called by `applyAcceptSettlement` /
7579
- // `applyRejectSettlement` only after the §7 settlement matrix has cleared
7580
- // the run: if the page-layer returned per-op failures that imply unverified
7581
- // post-action state, the settlement helper routes to `needs_review` instead,
7582
- // and this helper is never called. The heavy payload is emptied here so
7583
- // stale refs never re-enter retention.
7584
- function applyTerminalTrackedChangeStatus(runId, status) {
7585
- const run = findRunRecord(runId);
7586
- if (!run || !TERMINAL_TRACKED_CHANGE_STATUS.has(status)) {
7587
- return;
7588
- }
7589
- run.trackedChangeStatus = status;
7590
- run.undoTrackedChanges = [];
7591
- run.undoExpectedFiles = [];
7592
- saveStateSoon();
7593
- refreshRunCardControls(runId);
7594
- }
7595
-
7596
- // Inline mirror of the content-side subset of the FailureReason §9 catalog
7597
- // used by T5 emit sites. Mirrors the page-side `PAGE_FAILURE_CATALOG` in
7598
- // `extension/src/page/writebackRouter.js` so neither runtime has to depend on
7599
- // load order with `shared/failureReasons.js`. Each entry mirrors stage /
7600
- // severity / defaultRetryable / fallbackUserMessage / fallbackNextAction
7601
- // from `FAILURE_CODE_CATALOG`; keep in sync when adding codes.
7602
- const CONTENT_FAILURE_CATALOG = {
7603
- project_snapshot_unavailable: {
7604
- stage: 'context', severity: 'error', defaultRetryable: true,
7605
- fallbackUserMessage: 'Codex could not read the Overleaf project snapshot.',
7606
- fallbackNextAction: 'Refresh Overleaf, then rerun the task.'
7607
- },
7608
- selected_context_unresolved: {
7609
- stage: 'context', severity: 'warning', defaultRetryable: true,
7610
- fallbackUserMessage: 'Codex could not resolve the requested selection or context.',
7611
- fallbackNextAction: 'Select the target again or specify the file/section explicitly.'
7612
- },
7613
- codex_no_usable_result: {
7614
- stage: 'codex', severity: 'error', defaultRetryable: true,
7615
- fallbackUserMessage: 'Local Codex returned no usable final report or operations.',
7616
- fallbackNextAction: 'Open Technical Details and resolve the local Codex error.'
7617
- },
7618
- codex_project_locked: {
7619
- stage: 'codex', severity: 'blocked', defaultRetryable: true,
7620
- fallbackUserMessage: 'Another Codex task is already running for this Overleaf project.',
7621
- fallbackNextAction: 'Wait for the active task to finish, or cancel it before retrying.'
7622
- },
7623
- storage_quota_exceeded: {
7624
- stage: 'storage', severity: 'warning', defaultRetryable: true,
7625
- fallbackUserMessage: 'Browser storage quota was exceeded.',
7626
- fallbackNextAction: 'Clear old run history or reduce attachments.'
7627
- },
7628
- native_bridge_unavailable: {
7629
- stage: 'native', severity: 'blocked', defaultRetryable: true,
7630
- fallbackUserMessage: 'Extension cannot connect to the Codex native host.',
7631
- fallbackNextAction: 'Run install-native or reload the extension.'
7632
- },
7633
- undo_not_verified: {
7634
- stage: 'undo', severity: 'warning', defaultRetryable: true,
7635
- fallbackUserMessage: 'Undo ran, but Codex could not prove the file returned to pre-run content.',
7636
- fallbackNextAction: 'Inspect the file manually before continuing.'
7637
- },
7638
- accept_not_verified: {
7639
- stage: 'accept', severity: 'warning', defaultRetryable: true,
7640
- fallbackUserMessage: 'Accept appeared to run but Codex could not prove final content/state.',
7641
- fallbackNextAction: 'Inspect Overleaf Reviewing before continuing.'
7642
- }
7643
- };
7644
-
7645
- // Build a structured FailureReason record (§7) for emit at the content
7646
- // runtime layer. `overrides` merges into the entry: callers supply `file` /
7647
- // `activeFile` / `userMessage` / `evidence` / `changedDocument` /
7648
- // `terminalState` etc. The `op` argument is the operation context that
7649
- // augments `file` / `operationType` when not explicitly overridden.
7650
- function buildContentFailure(code, op, overrides) {
7651
- // The local catalog holds content-runtime-specific codes; every other
7652
- // code (codex_not_found, codex_timeout, codex_output_limit,
7653
- // stale_source_changed, native_protocol_incompatible, ...) resolves
7654
- // through the shared §9 catalog. Without this fallback the function
7655
- // returned null for every shared-only code, so completion reports
7656
- // carried no structured failure and the recovery-action registry never
7657
- // fired for them (v1.7.6 fleet P1).
7658
- const entry = CONTENT_FAILURE_CATALOG[code] || FailureReasons?.FAILURE_CODE_CATALOG?.[code];
7659
- if (!entry) {
7660
- return null;
7661
- }
7662
- const merged = overrides || {};
7663
- const opCtx = op || {};
7664
- const failure = {
7018
+ function buildContentFailure(code, operation, overrides) {
7019
+ return WritebackSettlement.buildContentFailure(
7665
7020
  code,
7666
- stage: entry.stage,
7667
- severity: entry.severity,
7668
- userMessage: merged.userMessage || entry.fallbackUserMessage,
7669
- retryable: merged.retryable === undefined ? entry.defaultRetryable : merged.retryable === true,
7670
- nextAction: merged.nextAction || entry.fallbackNextAction
7671
- };
7672
- const file = merged.file !== undefined ? merged.file : opCtx.path;
7673
- if (file) failure.file = file;
7674
- const operationType = merged.operationType !== undefined ? merged.operationType : opCtx.type;
7675
- if (operationType) failure.operationType = operationType;
7676
- if (merged.activeFile !== undefined) failure.activeFile = merged.activeFile;
7677
- if (merged.changedDocument !== undefined) failure.changedDocument = merged.changedDocument === true;
7678
- if (merged.terminalState !== undefined) failure.terminalState = merged.terminalState;
7679
- if (merged.technicalMessage !== undefined) failure.technicalMessage = merged.technicalMessage;
7680
- if (merged.evidence !== undefined) failure.evidence = merged.evidence;
7681
- return failure;
7682
- }
7683
-
7684
- // Accept / Undo has a two-state user lifecycle: actionable before the page
7685
- // action, terminal after a successful page action. Warning-class verification
7686
- // evidence can still be recorded in technical details, but it must not keep
7687
- // the primary buttons actionable after Overleaf accepted the operation.
7688
-
7689
- // Codes that imply post-Accept proof is missing or contradicted. A page-side
7690
- // emitter (T4) is expected to fill these in; render-time normalization from
7691
- // the FailureReasons module repairs legacy `{ ok: false, code, reason }`
7692
- // shapes into the same structured failure record so this matrix works
7693
- // uniformly across both shapes.
7694
- const ACCEPT_NEEDS_REVIEW_CODES = new Set([
7695
- 'tracked_changes_remain',
7696
- 'accept_not_verified',
7697
- 'tracked_changes_created_unexpectedly',
7698
- 'accept_replay_created_tracked_changes',
7699
- 'write_observed_mismatch'
7700
- ]);
7701
-
7702
- const REJECT_NEEDS_REVIEW_CODES = new Set([
7703
- 'undo_not_verified',
7704
- 'undo_operation_failed',
7705
- 'undo_reviewing_restore_unverified',
7706
- 'tracked_change_nodes_not_identified',
7707
- 'tracked_changes_remain',
7708
- 'write_observed_mismatch'
7709
- ]);
7710
-
7711
- // Walks the page-layer result's applied/skipped lists, normalizing each
7712
- // item's `result` (whether structured `failure` or legacy `code` + `reason`)
7713
- // into a `FailureReason` record. Used by `applyAcceptSettlement` /
7714
- // `applyRejectSettlement` to decide between `accepted`/`rejected`,
7715
- // `needs_review`, and "stay pending" (blocked).
7716
- function collectFailuresFromResult(result) {
7717
- const failures = [];
7718
- if (!result || typeof result !== 'object') {
7719
- return failures;
7720
- }
7721
- if (FailureReasons && FailureReasons.normalizeFailureReason instanceof Function) {
7722
- for (const entry of Array.isArray(result.skipped) ? result.skipped : []) {
7723
- const inner = (entry && entry.result) || entry;
7724
- if (!inner || inner.ok === true) continue;
7725
- const operation = (entry && entry.operation) || (entry && entry.trackedChange ? { path: entry.trackedChange.path } : undefined);
7726
- failures.push(FailureReasons.normalizeFailureReason(inner, operation));
7727
- }
7728
- for (const entry of Array.isArray(result.applied) ? result.applied : []) {
7729
- const inner = entry && entry.result;
7730
- if (!inner || inner.ok !== false) continue;
7731
- const operation = (entry && entry.operation) || (entry && entry.trackedChange ? { path: entry.trackedChange.path } : undefined);
7732
- failures.push(FailureReasons.normalizeFailureReason(inner, operation));
7733
- }
7734
- } else {
7735
- // Defensive fallback: the FailureReasons module should be present in
7736
- // production (loaded by the content script bundle) but tests that strip
7737
- // it out should still see the legacy code-only path treated as a
7738
- // generic non-blocking failure.
7739
- for (const entry of Array.isArray(result.skipped) ? result.skipped : []) {
7740
- const inner = (entry && entry.result) || entry;
7741
- if (inner && inner.ok !== true) {
7742
- failures.push({
7743
- code: (inner && typeof inner.code === 'string' ? inner.code : '') || 'unknown_legacy_failure',
7744
- severity: 'error'
7745
- });
7746
- }
7747
- }
7748
- }
7749
- return failures;
7750
- }
7751
-
7752
- // Run-card settlement for Accept changes. Three branches:
7753
- // 1. The primary failure is `blocked` (e.g. preflight / navigation refused
7754
- // to touch Overleaf) — stay pending so the user can retry without the
7755
- // card claiming terminal accepted.
7756
- // 2. Any successful page-side accept work — settle as terminal `accepted`.
7757
- // 3. Otherwise, unverified/no-op failures can remain retryable.
7758
- function applyAcceptSettlement(runId, result) {
7759
- const failures = collectFailuresFromResult(result);
7760
- const primary = FailureReasons && FailureReasons.selectPrimaryFailure instanceof Function
7761
- ? FailureReasons.selectPrimaryFailure(failures)
7762
- : (failures[0] || null);
7763
- if (primary && primary.terminalState === 'blocked') {
7764
- // Stay pending — page bridge declined the operation before any document
7765
- // change. The preceding result event already showed the user the reason.
7766
- refreshRunCardControls(runId);
7767
- return;
7768
- }
7769
- if (isSuccessfulTrackedChangeSettlement(result)) {
7770
- applyTerminalTrackedChangeStatus(runId, 'accepted');
7771
- return;
7772
- }
7773
- const needsReview = failures.some(failure =>
7774
- failure.terminalState === 'needs_review' ||
7775
- ACCEPT_NEEDS_REVIEW_CODES.has(failure.code)
7021
+ operation,
7022
+ overrides,
7023
+ FailureReasons
7776
7024
  );
7777
- if (needsReview) {
7778
- applyNeedsReviewTrackedChangeStatus(runId);
7779
- return;
7780
- }
7781
- applyTerminalTrackedChangeStatus(runId, 'accepted');
7782
- }
7783
-
7784
- // Same three-branch shape as `applyAcceptSettlement`, using the undo-side
7785
- // unverified-proof codes only when no successful reject/undo work happened.
7786
- function applyRejectSettlement(runId, result) {
7787
- const failures = collectFailuresFromResult(result);
7788
- const primary = FailureReasons && FailureReasons.selectPrimaryFailure instanceof Function
7789
- ? FailureReasons.selectPrimaryFailure(failures)
7790
- : (failures[0] || null);
7791
- if (primary && primary.terminalState === 'blocked') {
7792
- refreshRunCardControls(runId);
7793
- return;
7794
- }
7795
- if (isSuccessfulTrackedChangeSettlement(result)) {
7796
- applyTerminalTrackedChangeStatus(runId, 'rejected');
7797
- return;
7798
- }
7799
- const needsReview = failures.some(failure =>
7800
- failure.terminalState === 'needs_review' ||
7801
- REJECT_NEEDS_REVIEW_CODES.has(failure.code)
7802
- );
7803
- if (needsReview) {
7804
- applyNeedsReviewTrackedChangeStatus(runId);
7805
- return;
7806
- }
7807
- applyTerminalTrackedChangeStatus(runId, 'rejected');
7808
- }
7809
-
7810
- function isSuccessfulTrackedChangeSettlement(result) {
7811
- if (!result || typeof result !== 'object') {
7812
- return false;
7813
- }
7814
- if (result.ok === true) {
7815
- return true;
7816
- }
7817
- if (!Array.isArray(result.applied)) {
7818
- return false;
7819
- }
7820
- return result.applied.some(entry => {
7821
- const inner = entry && (entry.result || entry);
7822
- return !inner || inner.ok !== false;
7823
- });
7824
7025
  }
7825
7026
 
7826
- // Sets `needs_review` on a run without emptying refs — the user is supposed
7827
- // to retry Accept or Undo after inspecting Overleaf, so the heavy payload
7828
- // (tracked-change refs + expected files) must survive.
7829
- function applyNeedsReviewTrackedChangeStatus(runId) {
7027
+ function applyTrackedChangeSettlement(runId, kind, result) {
7830
7028
  const run = findRunRecord(runId);
7831
- if (!run) {
7029
+ const settlement = WritebackSettlement.settleTrackedChangeLifecycle({
7030
+ kind,
7031
+ run,
7032
+ result,
7033
+ failureReasons: FailureReasons
7034
+ });
7035
+ if (settlement.decision === 'blocked') {
7036
+ refreshRunCardControls(runId);
7832
7037
  return;
7833
7038
  }
7834
- run.trackedChangeStatus = 'needs_review';
7039
+ Object.assign(run, WritebackSettlement.applySettlementTransition(run, settlement));
7835
7040
  saveStateSoon();
7836
7041
  refreshRunCardControls(runId);
7837
7042
  }
@@ -7908,13 +7113,7 @@
7908
7113
  // possibly-divergent base. This keeps wide paragraph patches safe: their
7909
7114
  // whole-paragraph `expected` would otherwise silently fail to re-apply.
7910
7115
  function attachVerifiedContentToOperation(operation, result) {
7911
- if (!operation || typeof operation !== 'object') {
7912
- return operation;
7913
- }
7914
- if (operation.type === 'edit' && typeof result?.verifiedContent === 'string') {
7915
- return { ...operation, verifiedContent: result.verifiedContent };
7916
- }
7917
- return operation;
7116
+ return WritebackSettlement.attachVerifiedContentToOperation(operation, result);
7918
7117
  }
7919
7118
 
7920
7119
  function recordUndoFromApply(project, applyResult) {
@@ -7938,15 +7137,20 @@
7938
7137
  ];
7939
7138
  record.appliedOperations = combinedAppliedOperations;
7940
7139
 
7941
- if (state.requireReviewing === true) {
7942
- const combinedTrackedChanges = mergeTrackedChanges([
7140
+ if ((currentRunView?.executionSnapshot?.requireReviewing ?? state.requireReviewing) === true) {
7141
+ const combinedTrackedChanges = normalizeApplyTrackedChanges([
7943
7142
  ...(Array.isArray(record.undoTrackedChanges) ? record.undoTrackedChanges : []),
7944
7143
  ...trackedChanges
7945
7144
  ]);
7946
7145
  record.undoOperations = [];
7947
7146
  record.undoBaseFiles = [];
7948
7147
  record.undoTrackedChanges = combinedTrackedChanges;
7949
- record.undoExpectedFiles = selectExpectedFilesForTrackedUndo(project, combinedAppliedOperations, combinedTrackedChanges);
7148
+ record.undoExpectedFiles = selectExpectedFilesForTrackedUndo(
7149
+ project,
7150
+ combinedAppliedOperations,
7151
+ combinedTrackedChanges,
7152
+ record.undoExpectedFiles
7153
+ );
7950
7154
  record.undoStatus = '';
7951
7155
  record.partialWriteback = skippedEntries.length > 0;
7952
7156
  // Recording tracked-change refs enters the run into the tracked-change
@@ -7994,7 +7198,12 @@
7994
7198
  record.undoOperations = checkpoint.undoOperations;
7995
7199
  record.undoBaseFiles = checkpoint.undoBaseFiles;
7996
7200
  record.undoTrackedChanges = [];
7997
- record.undoExpectedFiles = selectExpectedFilesForTrackedUndo(project, combinedAppliedOperations, []);
7201
+ record.undoExpectedFiles = selectExpectedFilesForTrackedUndo(
7202
+ project,
7203
+ combinedAppliedOperations,
7204
+ [],
7205
+ record.undoExpectedFiles
7206
+ );
7998
7207
  record.undoStatus = '';
7999
7208
  record.partialWriteback = skippedEntries.length > 0;
8000
7209
  refreshRunCardControls(record.id);
@@ -8011,49 +7220,21 @@
8011
7220
  }
8012
7221
 
8013
7222
  function normalizeApplyTrackedChanges(changes = []) {
8014
- const seen = new Set();
8015
- const normalized = [];
8016
- for (const change of changes || []) {
8017
- const key = typeof change?.key === 'string' ? change.key : '';
8018
- if (!key || seen.has(key)) {
8019
- continue;
8020
- }
8021
- seen.add(key);
8022
- normalized.push({
8023
- key,
8024
- id: typeof change.id === 'string' ? change.id : '',
8025
- path: typeof change.path === 'string' ? change.path : '',
8026
- label: typeof change.label === 'string' ? change.label : ''
8027
- });
8028
- }
8029
- return normalized;
8030
- }
8031
-
8032
- function mergeTrackedChanges(changes = []) {
8033
- return normalizeApplyTrackedChanges(changes);
7223
+ return WritebackSettlement.normalizeApplyTrackedChanges(changes);
8034
7224
  }
8035
7225
 
8036
- function selectExpectedFilesForTrackedUndo(project, operations = [], trackedChanges = []) {
8037
- const paths = new Set();
8038
- for (const change of trackedChanges || []) {
8039
- if (change.path) {
8040
- paths.add(change.path);
8041
- }
8042
- }
8043
- for (const operation of operations || []) {
8044
- if (operation?.path) {
8045
- paths.add(operation.path);
8046
- }
8047
- if (operation?.to) {
8048
- paths.add(operation.to);
8049
- }
8050
- }
8051
- return (project?.files || [])
8052
- .filter(file => paths.has(file.path) && typeof file.content === 'string')
8053
- .map(file => ({
8054
- path: file.path,
8055
- content: file.content
8056
- }));
7226
+ function selectExpectedFilesForTrackedUndo(
7227
+ project,
7228
+ operations = [],
7229
+ trackedChanges = [],
7230
+ previousExpectedFiles = []
7231
+ ) {
7232
+ return WritebackSettlement.selectExpectedFilesForTrackedUndo(
7233
+ project,
7234
+ operations,
7235
+ trackedChanges,
7236
+ previousExpectedFiles
7237
+ );
8057
7238
  }
8058
7239
 
8059
7240
  function buildTrackedUndoPostFiles(run) {
@@ -8136,6 +7317,21 @@
8136
7317
  refreshRunCardControls(runId);
8137
7318
  }
8138
7319
 
7320
+ function applyLegacyUndoSettlement(runId, undoStatus, result) {
7321
+ const run = findRunRecord(runId);
7322
+ if (!run) return;
7323
+ Object.assign(run, WritebackSettlement.applySettlementTransition(
7324
+ run,
7325
+ WritebackSettlement.settleLegacyUndo({
7326
+ run,
7327
+ status: undoStatus,
7328
+ result
7329
+ })
7330
+ ));
7331
+ saveStateSoon();
7332
+ refreshRunCardControls(runId);
7333
+ }
7334
+
8139
7335
  function refreshRunCardControls(runId) {
8140
7336
  const run = findRunRecord(runId);
8141
7337
  const root = panel?.querySelector(`[data-run-id="${cssEscape(runId)}"]`);
@@ -8290,13 +7486,16 @@
8290
7486
  const operations = input.operations || [];
8291
7487
  const applyResults = input.applyResults || [];
8292
7488
  const record = currentRunView?.recordId ? findRunRecord(currentRunView.recordId, currentRunView.sessionId) : null;
8293
- const undoCount = getRunUndoCount(record);
7489
+ const settlementProjection = WritebackSettlement.projectRunSettlement(record || {});
7490
+ const undoCount = settlementProjection.canUndo ? getRunUndoCount(record) : 0;
8294
7491
  const report = buildHumanCompletionReport({
8295
7492
  ...input,
8296
7493
  locale: getLocale(),
8297
7494
  operations,
8298
7495
  applyResults,
8299
7496
  undoCount,
7497
+ settlementProjection,
7498
+ failure: input.failure || settlementProjection.primaryFailure || undefined,
8300
7499
  includeWriteResult: true
8301
7500
  });
8302
7501
 
@@ -8315,7 +7514,7 @@
8315
7514
  // which recovery button to render. (Fixed in v1.7.5: callers passed
8316
7515
  // input.failure since v1.6.2 but it was never forwarded, so recovery
8317
7516
  // buttons on completion reports could never appear.)
8318
- failure: input.failure || undefined,
7517
+ failure: input.failure || settlementProjection.primaryFailure || undefined,
8319
7518
  compileErrors: input.compileErrors || undefined,
8320
7519
  rejectedHunks: input.rejectedHunks || currentRunView?.rejectedHunks || undefined
8321
7520
  });