codex-overleaf-link 2.5.0 → 2.5.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 (168) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +39 -27
  3. package/extension/bootstrap/background.js +52 -9
  4. package/extension/bootstrap/manifest.template.json +1 -1
  5. package/extension/bootstrap/popup.html +1 -0
  6. package/extension/bootstrap/popupFallback.js +55 -0
  7. package/extension/bootstrap/updateStatus.js +1 -0
  8. package/extension/src/background.js +206 -53
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +74 -14
  11. package/extension/src/backgroundUpdateCoordinator.js +118 -21
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +8 -2
  14. package/extension/src/content/applyResultFormatters.js +46 -73
  15. package/extension/src/content/assetTransferBroker.js +72 -14
  16. package/extension/src/content/composerAttachments.js +93 -45
  17. package/extension/src/content/composerPanel.js +18 -0
  18. package/extension/src/content/contentRuntime.js +661 -676
  19. package/extension/src/content/diagnosticsController.js +122 -96
  20. package/extension/src/content/diagnosticsExport.js +19 -5
  21. package/extension/src/content/diagnosticsPanel.js +30 -0
  22. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  23. package/extension/src/content/generated/content.bundle.js +6879 -2786
  24. package/extension/src/content/generated/content.bundle.meta.json +11 -3
  25. package/extension/src/content/globalPreferencesController.js +1 -1
  26. package/extension/src/content/legacyGlobalRegistry.js +6 -0
  27. package/extension/src/content/localSkillsPanel.js +102 -12
  28. package/extension/src/content/markdownText.js +5 -6
  29. package/extension/src/content/modelPicker.js +7 -4
  30. package/extension/src/content/nativeCompatibilityController.js +45 -20
  31. package/extension/src/content/otWarmMirror.js +4 -5
  32. package/extension/src/content/otWarmMirrorController.js +17 -20
  33. package/extension/src/content/pageBridgeClient.js +15 -2
  34. package/extension/src/content/panelMaintenance.js +40 -12
  35. package/extension/src/content/panelRenderer.js +6 -0
  36. package/extension/src/content/projectProviderSelection.js +13 -4
  37. package/extension/src/content/projectSessionCleanup.js +109 -62
  38. package/extension/src/content/projectSettingsCoordinator.js +39 -2
  39. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  40. package/extension/src/content/providerSettingsDialog.js +71 -13
  41. package/extension/src/content/recentProjects.js +11 -5
  42. package/extension/src/content/runActivitySummary.js +40 -6
  43. package/extension/src/content/runCompletionMeta.js +344 -0
  44. package/extension/src/content/runController.js +37 -14
  45. package/extension/src/content/runFailureNotice.js +21 -12
  46. package/extension/src/content/runGuidanceController.js +72 -27
  47. package/extension/src/content/runGuidanceView.js +59 -10
  48. package/extension/src/content/runQueueScheduler.js +148 -42
  49. package/extension/src/content/runRecoveryActions.js +146 -0
  50. package/extension/src/content/runResultActions.js +8 -125
  51. package/extension/src/content/runReviewController.js +574 -0
  52. package/extension/src/content/runScrollLayout.js +21 -2
  53. package/extension/src/content/runTimelineView.js +146 -153
  54. package/extension/src/content/scopedPersistencePanelState.js +51 -6
  55. package/extension/src/content/selectionContextView.js +1 -1
  56. package/extension/src/content/sensitivePreflight.js +140 -0
  57. package/extension/src/content/sessionForkController.js +80 -17
  58. package/extension/src/content/sessionManager.js +1 -1
  59. package/extension/src/content/settingsPanel.js +7 -3
  60. package/extension/src/content/settingsWorkbench.js +5 -0
  61. package/extension/src/content/sharedSessionViewSync.js +45 -8
  62. package/extension/src/content/updateIdle.js +4 -1
  63. package/extension/src/content/updateNotice.js +60 -8
  64. package/extension/src/content/updateSafetyReceiver.js +58 -0
  65. package/extension/src/content/writebackController.js +53 -4
  66. package/extension/src/content/writebackOrchestrator.js +82 -73
  67. package/extension/src/content/writingStyleSettings.js +35 -11
  68. package/extension/src/page/binaryAssetUploader.js +136 -14
  69. package/extension/src/page/fileMover.js +256 -0
  70. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  71. package/extension/src/page/referenceProjects.js +1 -0
  72. package/extension/src/page/reviewContext.js +113 -0
  73. package/extension/src/page/saveState.js +6 -4
  74. package/extension/src/page/snapshotRouter.js +84 -39
  75. package/extension/src/page/textFileCreator.js +105 -25
  76. package/extension/src/page/trackedChangeCapture.js +12 -2
  77. package/extension/src/page/trackedChangeOwnership.js +19 -1
  78. package/extension/src/page/trackedChangeReplay.js +41 -21
  79. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  80. package/extension/src/page/treeOperations.js +7 -10
  81. package/extension/src/page/updateSafetyProbe.js +41 -0
  82. package/extension/src/page/writebackReceiptJournal.js +2 -0
  83. package/extension/src/page/writebackRouter.js +80 -79
  84. package/extension/src/pageBridge.js +19 -3
  85. package/extension/src/popup.js +62 -10
  86. package/extension/src/shared/agentTranscript.js +23 -0
  87. package/extension/src/shared/auditRecords.js +2 -0
  88. package/extension/src/shared/compatibility.js +22 -6
  89. package/extension/src/shared/compileAdapter.js +79 -57
  90. package/extension/src/shared/composerAttachmentDraft.js +151 -0
  91. package/extension/src/shared/directoryCleanupProof.js +43 -0
  92. package/extension/src/shared/failureReasons.js +309 -3
  93. package/extension/src/shared/globalPreferences.js +30 -13
  94. package/extension/src/shared/governanceRules.js +4 -37
  95. package/extension/src/shared/i18n.js +298 -2
  96. package/extension/src/shared/managedUpdateProjection.js +2 -2
  97. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  98. package/extension/src/shared/pageRpcContract.js +5 -4
  99. package/extension/src/shared/projectFiles.js +21 -44
  100. package/extension/src/shared/providerProfiles.js +5 -3
  101. package/extension/src/shared/runInputQueue.js +48 -5
  102. package/extension/src/shared/selectionContext.js +12 -3
  103. package/extension/src/shared/sessionState.js +72 -27
  104. package/extension/src/shared/settlementFacts.js +10 -2
  105. package/extension/src/shared/sharedSessionBridge.js +37 -14
  106. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  107. package/extension/src/shared/staleGuard.js +118 -13
  108. package/extension/src/shared/storageDb.js +58 -26
  109. package/extension/src/shared/storageMigration.js +71 -111
  110. package/extension/src/shared/storageRunActions.js +209 -26
  111. package/extension/src/shared/subagentActivityModel.js +1 -1
  112. package/extension/src/shared/undoOperations.js +136 -13
  113. package/extension/src/shared/updateConsent.js +6 -4
  114. package/extension/src/shared/updateRuntimeIdentity.js +1 -1
  115. package/extension/src/shared/writebackIntent.js +16 -0
  116. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  117. package/extension/src/shared/writebackSettlement.js +38 -38
  118. package/extension/styles/panel.css +19 -5
  119. package/native-host/src/anthropicBridgeRequest.js +4 -2
  120. package/native-host/src/anthropicBridgeResponse.js +6 -2
  121. package/native-host/src/anthropicMessagesBridge.js +24 -16
  122. package/native-host/src/bootstrapMigration.js +296 -0
  123. package/native-host/src/bootstrapMigrationContract.js +101 -0
  124. package/native-host/src/bootstrapUpdate.js +233 -0
  125. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  126. package/native-host/src/chatBridgeResponse.js +10 -0
  127. package/native-host/src/chatCompletionsBridge.js +21 -15
  128. package/native-host/src/codexConfigPaths.js +106 -0
  129. package/native-host/src/codexHome.js +66 -73
  130. package/native-host/src/codexHomeScope.js +124 -0
  131. package/native-host/src/codexInheritedAuth.js +90 -0
  132. package/native-host/src/codexLocalSkills.js +301 -0
  133. package/native-host/src/codexPromptAssembly.js +31 -12
  134. package/native-host/src/codexProviderTest.js +24 -26
  135. package/native-host/src/codexSessionRunner.js +110 -183
  136. package/native-host/src/codexSessionTiming.js +18 -8
  137. package/native-host/src/codexSkillIsolation.js +170 -0
  138. package/native-host/src/codexThreadFork.js +19 -3
  139. package/native-host/src/index.js +19 -7
  140. package/native-host/src/managedInstall.js +110 -35
  141. package/native-host/src/managedInstallRecovery.js +140 -0
  142. package/native-host/src/managedRuntimeEvidence.js +57 -0
  143. package/native-host/src/mirrorWorkspace.js +92 -22
  144. package/native-host/src/nativeDoctor.js +15 -0
  145. package/native-host/src/nativeHelperLifecycle.js +21 -0
  146. package/native-host/src/nativeHostRegistration.js +33 -0
  147. package/native-host/src/nativeRunLifecycle.js +71 -0
  148. package/native-host/src/processStopBarrier.js +42 -7
  149. package/native-host/src/projectPathPlatform.js +30 -0
  150. package/native-host/src/providerHttp.js +80 -0
  151. package/native-host/src/providerProfile.js +4 -2
  152. package/native-host/src/providerReasoning.js +2 -7
  153. package/native-host/src/providerRuntime.js +1 -0
  154. package/native-host/src/providerStore.js +72 -14
  155. package/native-host/src/responsesPassthroughBridge.js +266 -52
  156. package/native-host/src/runtimeInstaller.js +21 -1
  157. package/native-host/src/selectionScope.js +36 -8
  158. package/native-host/src/subagentAdmission.js +26 -0
  159. package/native-host/src/subagentBroker.js +79 -66
  160. package/native-host/src/subagentTelemetry.js +19 -10
  161. package/native-host/src/subagentWorkspacePath.js +5 -2
  162. package/native-host/src/taskRunnerRuntime.js +77 -54
  163. package/native-host/src/tomlStatements.js +140 -0
  164. package/native-host/src/updateManager.js +297 -39
  165. package/native-host/src/writingStyleRuntime.js +57 -29
  166. package/native-host/src/writingStyleStore.js +73 -0
  167. package/package.json +1 -1
  168. package/scripts/install-managed.mjs +1 -0
@@ -56,7 +56,12 @@
56
56
  'codex.history.clearPlugin',
57
57
  'skills.list',
58
58
  'skills.install',
59
- 'skills.remove'
59
+ 'skills.remove',
60
+ 'writing-style.get',
61
+ 'writing-style.set',
62
+ 'codex.thread.fork',
63
+ 'asset.readChunk',
64
+ 'asset.release'
60
65
  ]);
61
66
  const PageRpcContract = Modules.PageRpcContract;
62
67
  const PANEL_DEFAULT_WIDTH = 380;
@@ -64,7 +69,9 @@
64
69
  const PANEL_MAX_WIDTH = 760;
65
70
  const PAGE_MIN_WIDTH = 520;
66
71
  const CodexOverleafCompatibility = Modules.Compatibility;
67
- const INSTALL_COMMAND = CodexOverleafCompatibility?.buildInstallCommand?.(
72
+ const MANAGED_INSTALLATION = chrome.runtime.getManifest?.()?.background?.service_worker === 'bootstrap/background.js'
73
+ || root.__CODEX_OVERLEAF_RUNTIME_PREFIX__ === 'runtime/';
74
+ const INSTALL_COMMAND = MANAGED_INSTALLATION ? '' : CodexOverleafCompatibility?.buildInstallCommand?.(
68
75
  undefined,
69
76
  undefined,
70
77
  getCurrentExtensionId()
@@ -207,6 +214,8 @@
207
214
  closeSlashMenu: () => closeSlashMenu(),
208
215
  getCurrentProjectId: () => getCurrentProjectId(),
209
216
  getLocalSkillsPanel: () => localSkillsPanel,
217
+ getCodexLocalSkills: () => getCodexLocalSkillsCatalog(),
218
+ onSkillsChange: () => updateSlashMenuForTaskInput(),
210
219
  getLocale: () => getLocale(),
211
220
  getPanel: () => panel,
212
221
  getPanelRendererInstance: () => panelRendererInstance,
@@ -229,6 +238,9 @@
229
238
  closeCustomInstructionsSettings,
230
239
  closeSkillsView,
231
240
  getCodexOverleafSkillEnabled,
241
+ getCodexLocalSkillEnabled,
242
+ isCodexLocalSkillEnabled,
243
+ setCodexLocalSkillEnabled,
232
244
  getCustomInstructionsForCurrentProject,
233
245
  getGovernanceRulesForCurrentProject,
234
246
  getSkillLoadingSettings,
@@ -335,6 +347,7 @@
335
347
  otWarmMirrorController,
336
348
  getActiveFocusFiles,
337
349
  getCurrentProjectId,
350
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
338
351
  isExperimentalOtEnabled,
339
352
  readOtBridgeStatus,
340
353
  formatOtStatusLabel,
@@ -383,6 +396,11 @@
383
396
  openCustomInstructionsSettings: () => openCustomInstructionsSettings(),
384
397
  onHistoryRowJump: record => jumpToHistoryRun(record),
385
398
  resetProjectNameCache: () => recentProjects.resetProjectNameCache(),
399
+ clearHistoryComposer: () => {
400
+ pendingRetryReplacement = null;
401
+ clearTaskComposer();
402
+ persistComposerAttachmentsToState();
403
+ },
386
404
  getPanel: () => panel,
387
405
  getState: () => state,
388
406
  setState: next => { state = next; },
@@ -407,8 +425,15 @@
407
425
  SessionState: Modules.SessionState,
408
426
  getState: () => state,
409
427
  setState: next => { state = next; },
410
- sendBackgroundNative,
411
- saveState,
428
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
429
+ sendBackgroundNative, saveState,
430
+ getEnabledCodexLocalSkillIds: () => getEnabledCodexLocalSkillIds(),
431
+ captureLocalSkillIdsForFork: async preferences => {
432
+ if (preferences.loadCodexLocalSkills === false) return [];
433
+ const choices = { ...preferences.codexLocalSkillEnabled };
434
+ const skills = await primeCodexLocalSkillsCatalog({ required: true });
435
+ return skills.filter(skill => Object.hasOwn(choices, skill.id) ? choices[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
436
+ },
412
437
  applyStateToPanel,
413
438
  showPluginToast,
414
439
  tr
@@ -427,9 +452,14 @@
427
452
  RunActivityModel: Modules.RunActivityModel,
428
453
  SubagentActivityView: Modules.SubagentActivityView,
429
454
  RunGuidanceView: Modules.RunGuidanceView,
455
+ getPendingGuidance: run => runGuidanceController.getPending(run),
430
456
  RunResultActions: runResultActions,
431
457
  RunScrollLayout: Modules.RunScrollLayout,
432
458
  RunFailureNotice: Modules.RunFailureNotice,
459
+ I18n: i18n,
460
+ FailureReasons,
461
+ localizeVisibleReason: value => localizeVisibleReason(value),
462
+ localizeCompileReason: value => Modules.CompileAdapter.localizeCompileReason(value, getLocale()),
433
463
  tr,
434
464
  tx,
435
465
  getLocale,
@@ -457,6 +487,8 @@
457
487
  refillComposerForRetry,
458
488
  canEditRun: run => runController.isRetryReplacementEligible(run, WritebackSettlement.projectRunSettlement(run)),
459
489
  retryWriteback: retryRunWriteback,
490
+ getCurrentProjectId,
491
+ getAccountScopeId: () => cachedAccountScopeId,
460
492
  showNativeSetupGuidance: () => showNativeUpdateGuidanceModal({}),
461
493
  openProjectFileForFailure,
462
494
  openStorageSettings,
@@ -609,6 +641,14 @@
609
641
  locale: getLocale()
610
642
  };
611
643
  },
644
+ preflightProjectReferences: ({ project, assertCurrent }) => {
645
+ assertCurrent();
646
+ return Modules.SensitivePreflight.create({
647
+ SensitiveScan, normalizeGovernanceRules, isTextSnapshotFile, getCurrentProjectId,
648
+ appendRunEvent: () => {}, appendCompletionReport: () => {},
649
+ showPluginConfirm, tr, tx
650
+ }).run({ task: '', project, rules: getGovernanceRulesForCurrentProject(), assertCurrent });
651
+ },
612
652
  startBuild: async params => {
613
653
  const gate = await nativeCompatibilityController.ensureForMethod('codex.run');
614
654
  if (!gate.ok) throw new Error(gate.response?.error?.message || 'Native host update required.');
@@ -765,8 +805,10 @@
765
805
  window,
766
806
  getSettingsPanelInstance: () => settingsPanelInstance,
767
807
  getSelectedModel: () => state?.model || '', getSelectedProviderId: () => state?.providerId || 'builtin',
808
+ getSelectedProviderRevision: () => state?.providerRevision,
768
809
  setSelectedProviderId: projectProviderSelection.commit,
769
810
  confirmProviderSwitch: projectProviderSelection.confirmSwitch,
811
+ assertProviderSwitchAllowed: projectProviderSelection.assertSwitchAllowed,
770
812
  refreshModelOptions: loadModelOptions, persistInputs: persistPanelInputs
771
813
  });
772
814
  // v1.8.1 D3: the dashboard was a one-shot render — another tab finishing a
@@ -848,6 +890,7 @@
848
890
  tr,
849
891
  tx,
850
892
  appendPlainLog,
893
+ onChange: () => getComposerDraftPersistence().changed(),
851
894
  limits: {
852
895
  maxAttachmentBytes: MAX_COMPOSER_ATTACHMENT_BYTES,
853
896
  maxAttachmentTotalBytes: MAX_COMPOSER_ATTACHMENT_TOTAL_BYTES,
@@ -905,9 +948,15 @@
905
948
  saveState,
906
949
  sendBackgroundNative,
907
950
  getCurrentProjectId,
951
+ getScope: () => getCodexLocalSkillsCatalogScope(),
908
952
  getSkillLoadingSettings,
909
953
  isCodexOverleafSkillEnabled,
910
954
  setCodexOverleafSkillEnabled,
955
+ isCodexLocalSkillEnabled,
956
+ setCodexLocalSkillEnabled,
957
+ getCodexLocalSkills: () => getCodexLocalSkillsCatalog(),
958
+ getCodexLocalSkillWarnings: () => getCodexLocalSkillWarnings(),
959
+ refreshCodexLocalSkills: options => primeCodexLocalSkillsCatalog(options),
911
960
  setProjectSettingsStatus,
912
961
  tr,
913
962
  tx,
@@ -940,6 +989,7 @@
940
989
  let storageKey = LEGACY_STORAGE_KEY;
941
990
  let globalPreferencesController = null;
942
991
  let currentRunView = null;
992
+ let runSubmissionPending = false;
943
993
  let pendingRetryReplacement = null;
944
994
  let runQueueScheduler = null;
945
995
  let runInputCoordinator = null;
@@ -960,6 +1010,7 @@
960
1010
  let pendingStreamRenderEvents = new Map();
961
1011
  let storageNoticeKeys = new Set();
962
1012
  let composerSkillInvocation = null;
1013
+ let composerDraftRevision = 0;
963
1014
  let slashCodexOverleafSkills = [];
964
1015
  let slashCodexOverleafSkillsLoaded = false;
965
1016
  let slashCodexOverleafSkillsLoading = false;
@@ -1017,6 +1068,7 @@
1017
1068
  const diagnosticsExporter = Modules.DiagnosticsExport.create({
1018
1069
  root, window, document, Blob, URL, TextEncoder, AuditRecords, StorageDb: Modules.StorageDb,
1019
1070
  CodexOverleafCompatibility, getState: () => state, getCurrentRunView: () => currentRunView,
1071
+ getAccountScopeId: () => cachedAccountScopeId || '',
1020
1072
  getCurrentProjectId, findRunRecord, getMirrorFreshness, getExtensionCompatibilityMetadata,
1021
1073
  getModelDiscovery, getGovernanceRulesForCurrentProject, sendBackgroundNative,
1022
1074
  fallbackNativeCompatibility, isNativeCompatibilityCompatible, getNativeCompatibilityClassification,
@@ -1094,6 +1146,7 @@
1094
1146
  await refreshAccountScopeId();
1095
1147
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
1096
1148
  state = normalizeLoadedPanelState(await loadStoredState());
1149
+ await getRunReviewController().recoverInterruptedUndoStatuses();
1097
1150
  initializeRunQueueScheduler();
1098
1151
  recoverInterruptedRunJournals()
1099
1152
  .then(() => applyStateToPanel())
@@ -1165,7 +1218,8 @@
1165
1218
  version: CodexOverleafCompatibility?.BUILD_TARGET_VERSION ||
1166
1219
  chrome.runtime.getManifest?.().version ||
1167
1220
  '',
1168
- extensionId: getCurrentExtensionId()
1221
+ extensionId: getCurrentExtensionId(),
1222
+ installIdentity: { managed: MANAGED_INSTALLATION }
1169
1223
  };
1170
1224
  }
1171
1225
 
@@ -1309,6 +1363,7 @@
1309
1363
  onTaskKeydown: handleTaskInputKeydown,
1310
1364
  onTaskInput: handleTaskInput,
1311
1365
  onSlashMenuClick: handleSlashMenuClick,
1366
+ onMenuLayoutChange: updateSlashMenuLayout,
1312
1367
  onClearSkillInvocation: clearComposerSkillInvocation,
1313
1368
  onAddContext: toggleContextTray,
1314
1369
  onAttachClick: () => panel.querySelector('[data-attach-input]')?.click(),
@@ -1335,34 +1390,87 @@
1335
1390
  renderContextSelection();
1336
1391
  scheduleMirrorPrefetch({ reason: 'cold-start', delayMs: 2500 });
1337
1392
  primeCodexOverleafSkillsCatalog().catch(() => { /* non-blocking */ });
1393
+ primeCodexLocalSkillsCatalog().catch(() => { /* retry through settings, slash, or submission */ });
1338
1394
  }
1339
1395
 
1340
1396
  PanelRenderer.setVisible(panel, true);
1341
1397
  }
1342
1398
 
1343
- // One-shot catalog prime: the parallel-subagents broker gate reads
1344
- // state.codexOverleafSkills at run time. The catalog persists once filled
1345
- // (v1.6.1), but states saved by older builds have it stripped — without
1346
- // this prime, the gate stays silently off until the user happens to open
1347
- // the Skills page. Fire-and-forget on panel open; only fetches when the
1348
- // catalog is empty.
1349
- async function primeCodexOverleafSkillsCatalog() {
1399
+ // Panel prefetch and first submission share the same official catalog read.
1400
+ // A required read fails through the existing task-start error presentation.
1401
+ let codexOverleafSkillsCatalogPromise = null;
1402
+ async function primeCodexOverleafSkillsCatalog({ required = false } = {}) {
1350
1403
  if (Array.isArray(state?.codexOverleafSkills) && state.codexOverleafSkills.length) {
1351
- return;
1352
- }
1353
- if (getSkillLoadingSettings().loadCodexOverleafSkills === false) {
1354
- return;
1355
- }
1356
- const response = await sendBackgroundNative({
1357
- method: 'skills.list',
1358
- params: { scope: 'codex-overleaf' }
1404
+ return state.codexOverleafSkills;
1405
+ }
1406
+ if (!required && getSkillLoadingSettings().loadCodexOverleafSkills === false) return [];
1407
+ if (!codexOverleafSkillsCatalogPromise) {
1408
+ codexOverleafSkillsCatalogPromise = sendBackgroundNative({
1409
+ method: 'skills.list', params: { scope: 'codex-overleaf' }
1410
+ }).then(response => {
1411
+ const skills = Array.isArray(response?.result?.skills) ? response.result.skills : [];
1412
+ if (!response?.ok || !skills.length) throw new Error(tx(
1413
+ 'Could not load Codex Overleaf skills. Try again.', '无法加载 Codex Overleaf 技能,请重试。'));
1414
+ state = { ...state, codexOverleafSkills: skills };
1415
+ saveStateSoon();
1416
+ return skills;
1417
+ }).finally(() => { codexOverleafSkillsCatalogPromise = null; });
1418
+ }
1419
+ return codexOverleafSkillsCatalogPromise;
1420
+ }
1421
+
1422
+ // Local metadata stays in memory. A stale page/account read cannot become the
1423
+ // next page's selected catalog, and an empty successful directory is valid.
1424
+ let codexLocalSkillsCatalog = null;
1425
+ let codexLocalSkillsWarnings = [];
1426
+ let codexLocalSkillsCatalogScope = '';
1427
+ let codexLocalSkillsCatalogRequest = null;
1428
+ function getCodexLocalSkillsCatalogScope() {
1429
+ return [cachedAccountScopeId, getCurrentProjectId(), spaRouteGeneration].join(':');
1430
+ }
1431
+
1432
+ function getCodexLocalSkillsCatalog() {
1433
+ return codexLocalSkillsCatalogScope === getCodexLocalSkillsCatalogScope() ? codexLocalSkillsCatalog : null;
1434
+ }
1435
+
1436
+ function getCodexLocalSkillWarnings() {
1437
+ return codexLocalSkillsCatalogScope === getCodexLocalSkillsCatalogScope() ? codexLocalSkillsWarnings : [];
1438
+ }
1439
+
1440
+ async function primeCodexLocalSkillsCatalog({ required = false, force = false } = {}) {
1441
+ const scope = getCodexLocalSkillsCatalogScope();
1442
+ if (!required && !force && getSkillLoadingSettings().loadCodexLocalSkills === false) return [];
1443
+ if (!force && codexLocalSkillsCatalogScope === scope && Array.isArray(codexLocalSkillsCatalog)) return codexLocalSkillsCatalog;
1444
+ if (codexLocalSkillsCatalogRequest?.scope === scope) return codexLocalSkillsCatalogRequest.promise;
1445
+ const request = { scope, promise: null };
1446
+ request.promise = sendBackgroundNative({ method: 'skills.list', params: { scope: 'codex-local' } }).then(response => {
1447
+ if (!response?.ok || !Array.isArray(response.result?.skills)) throw new Error(tx(
1448
+ 'Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'));
1449
+ if (scope !== getCodexLocalSkillsCatalogScope()) throw new Error(tx(
1450
+ 'The active project changed while loading skills. Try again.', '加载技能时活动项目已改变,请重试。'));
1451
+ const seen = new Set();
1452
+ const skills = response.result.skills.filter(skill => {
1453
+ const id = String(skill?.id || '');
1454
+ if (!isSafeSkillId(id) || seen.has(id)) return false;
1455
+ seen.add(id); return true;
1456
+ });
1457
+ codexLocalSkillsCatalog = skills;
1458
+ codexLocalSkillsWarnings = (Array.isArray(response.result.discoveryWarnings) ? response.result.discoveryWarnings : [])
1459
+ .filter(warning => warning?.code === 'codex_local_skills_skipped' && Number.isSafeInteger(warning.count) && warning.count > 0)
1460
+ .map(warning => ({ code: warning.code, count: warning.count }));
1461
+ codexLocalSkillsCatalogScope = scope;
1462
+ updateSkillsEntrySummary();
1463
+ updateSlashMenuForTaskInput();
1464
+ return skills;
1465
+ }).catch(() => {
1466
+ if (scope !== getCodexLocalSkillsCatalogScope()) throw new Error(tx(
1467
+ 'The active project changed while loading skills. Try again.', '加载技能时活动项目已改变,请重试。'));
1468
+ throw new Error(tx('Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'));
1469
+ }).finally(() => {
1470
+ if (codexLocalSkillsCatalogRequest === request) codexLocalSkillsCatalogRequest = null;
1359
1471
  });
1360
- const skills = Array.isArray(response?.result?.skills) ? response.result.skills : [];
1361
- if (!response?.ok || !skills.length) {
1362
- return;
1363
- }
1364
- state = { ...state, codexOverleafSkills: skills };
1365
- saveStateSoon();
1472
+ codexLocalSkillsCatalogRequest = request;
1473
+ return request.promise;
1366
1474
  }
1367
1475
 
1368
1476
  function closePanel() {
@@ -1445,6 +1553,10 @@
1445
1553
  panel.querySelectorAll('[data-i18n]').forEach(element => {
1446
1554
  element.textContent = tr(element.dataset.i18n);
1447
1555
  });
1556
+ panel.querySelectorAll('[data-i18n-aria-label]').forEach(element => {
1557
+ element.setAttribute('aria-label', tr(element.dataset.i18nAriaLabel));
1558
+ });
1559
+ panelRendererInstance?.setLocale?.({ launcher: tx('Show or hide Codex panel', '显示或隐藏 Codex 边栏'), close: tx('Close Codex panel', '关闭 Codex 边栏') });
1448
1560
 
1449
1561
  setElementTitleAndAria('[data-panel-resize-handle]', tr('resizePanel'), tr('resizePanel'));
1450
1562
  setElementTitleAndAria('[data-refresh]', tr('refreshProbe'), tr('refreshProbe'));
@@ -1514,7 +1626,7 @@
1514
1626
  contextStatus.textContent = tr('contextStatus');
1515
1627
  }
1516
1628
  // Re-render rather than patch text: the first div is the animated mark.
1517
- if (panel.querySelector('.empty-runs')) renderRunHistory();
1629
+ if (arguments[0]?.reprojectDynamic !== false) runTimelineView.refreshLocale?.();
1518
1630
 
1519
1631
  syncModeControls();
1520
1632
  updateOtStatusDisplay();
@@ -1523,9 +1635,19 @@
1523
1635
  updateModelDisplay();
1524
1636
  renderSessionList();
1525
1637
  renderContextSelection();
1638
+ renderContextSummary();
1639
+ renderPendingInputs();
1640
+ renderComposerAttachments();
1641
+ renderComposerSkillInvocation();
1526
1642
  writingStyleSettings.sync();
1527
1643
  updateSkillsEntrySummary();
1644
+ localSkillsPanel.refreshLocale?.();
1528
1645
  providerSettingsCoordinator.renderSummary();
1646
+ providerSettingsCoordinator.refreshLocale?.();
1647
+ settingsPanelInstance?.refreshLocale?.();
1648
+ Modules.UpdateNotice?.refreshLocale?.();
1649
+ DiagnosticsPanel.refreshLocale?.(diagnosticsPanelInstance, diagnosticsController.refreshLocale?.());
1650
+ if (!panel.querySelector('[data-context-tray]')?.hidden) renderContextFiles();
1529
1651
  if (settingsPanelInstance?.container?.querySelector('[data-storage-card]')?.open) refreshStorageUsageSummary();
1530
1652
  }
1531
1653
 
@@ -1685,15 +1807,7 @@
1685
1807
  function resetContextProject() { return contextTrayController.resetContextProject(); }
1686
1808
 
1687
1809
  async function runTask(options = {}) {
1688
- // Barrier on a still-flying background mirror refresh from the previous
1689
- // turn (v1.7.5) BEFORE startRunView creates the new run view: the mirror's
1690
- // wrap-up events must land in the run that produced them, not the new
1691
- // one, and mirror.sync landing mid-run would swap the local baseline
1692
- // under Codex.
1693
- const pendingMirror = getPendingMirrorRefresh?.();
1694
- if (pendingMirror) {
1695
- await pendingMirror.catch(() => {});
1696
- }
1810
+ // Capture the click before any preparation can yield to another draft/session.
1697
1811
  readPanelInputs();
1698
1812
  const queuedInput = options.queuedInput || null;
1699
1813
  const task = String(queuedInput?.text || state.task || '').trim();
@@ -1701,6 +1815,11 @@
1701
1815
  appendLog(tx('Enter a task first.', '请先输入任务。'));
1702
1816
  return;
1703
1817
  }
1818
+ const submittedSessionId = state.activeSessionId;
1819
+ const submittedSession = runController.captureSessionContext(state);
1820
+ const submittedThreadId = getActiveSession(state)?.codexThreadId || '';
1821
+ const submittedDraft = state.task;
1822
+ const submittedRetryReplacement = pendingRetryReplacement;
1704
1823
  const submittedWritingStyleScope = { accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() };
1705
1824
  const submittedPanelState = {
1706
1825
  mode: state.mode,
@@ -1713,10 +1832,35 @@
1713
1832
  focusFiles: getActiveFocusFiles(),
1714
1833
  selectionContext: selectionContextView.capture()
1715
1834
  };
1716
- const submittedCustomInstructions = getCustomInstructionsForCurrentProject();
1717
- const submittedSkillLoadingSettings = getSkillLoadingSettings();
1718
- const submittedAttachments = getComposerAttachmentsForRun();
1719
- const submittedSkillInvocation = getComposerSkillInvocationForRun();
1835
+ const queuedContext = queuedInput
1836
+ ? RunInputQueue.normalizeSubmissionContext(queuedInput.payload?.submissionContext) : null;
1837
+ const submittedCustomInstructions = queuedContext ? queuedContext.customInstructions : getCustomInstructionsForCurrentProject();
1838
+ const submittedSkillLoadingSettings = queuedContext ? queuedContext.skillLoadingSettings : getSkillLoadingSettings();
1839
+ const submittedAttachments = queuedInput ? [] : getComposerAttachmentsForRun();
1840
+ const submittedSkillInvocation = queuedContext ? queuedContext.skillInvocation : getComposerSkillInvocationForRun();
1841
+ let submittedEnabledSkillIds = queuedContext ? queuedContext.enabledCodexOverleafSkillIds : getEnabledCodexOverleafSkillIds();
1842
+ const submittedSkillEnabled = { ...state.codexOverleafSkillEnabled };
1843
+ const submittedLocalSkillEnabled = { ...state.codexLocalSkillEnabled };
1844
+ const submittedSelectionRevision = selectionContextView.getRevision?.();
1845
+ let submittedEnabledLocalSkillIds = queuedContext ? queuedContext.enabledCodexLocalSkillIds : [];
1846
+ if (!queuedInput && submittedSkillLoadingSettings.loadCodexLocalSkills !== false) {
1847
+ const localSkills = await primeCodexLocalSkillsCatalog({ required: true, force: submittedSkillInvocation?.scope === 'codex-local' });
1848
+ submittedEnabledLocalSkillIds = localSkills.filter(skill => Object.hasOwn(submittedLocalSkillEnabled, skill.id)
1849
+ ? submittedLocalSkillEnabled[skill.id] !== false : skill.enabled !== false).map(skill => skill.id);
1850
+ }
1851
+ if (!queuedInput && submittedSkillInvocation?.scope === 'codex-local'
1852
+ && (submittedSkillLoadingSettings.loadCodexLocalSkills === false || !submittedEnabledLocalSkillIds.includes(submittedSkillInvocation.id))) {
1853
+ throw new Error(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
1854
+ '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'));
1855
+ }
1856
+ if (!queuedInput && submittedSkillLoadingSettings.loadCodexOverleafSkills !== false) {
1857
+ const skills = await primeCodexOverleafSkillsCatalog({ required: true });
1858
+ submittedEnabledSkillIds = skills.map(skill => String(skill?.id || '').trim())
1859
+ .filter(id => id && submittedSkillEnabled[id] !== false);
1860
+ }
1861
+ // Keep the previous mirror's completion events on its own run.
1862
+ const pendingMirror = getPendingMirrorRefresh?.();
1863
+ if (pendingMirror) await pendingMirror.catch(() => {});
1720
1864
  await providerSettingsCoordinator.ensureLoaded();
1721
1865
  if (!queuedInput) await writingStyleSettings.ensureLoaded(true);
1722
1866
  if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
@@ -1724,6 +1868,10 @@
1724
1868
  throw new Error(tx('The active project changed before submission. Submit the task again.',
1725
1869
  '提交前活动项目已改变,请重新提交任务。'));
1726
1870
  }
1871
+ if (!findSessionById(submittedSessionId) || currentRunView) {
1872
+ throw new Error(tx('The submitted session changed before submission. Submit the task again.',
1873
+ '提交所属会话已改变,请重新提交任务。'));
1874
+ }
1727
1875
  submittedPanelState.writingStyle = queuedInput ? null : writingStyleSettings.capture();
1728
1876
  const executionSnapshot = queuedInput
1729
1877
  ? RunExecutionSnapshot.resolveForExecution(
@@ -1735,9 +1883,9 @@
1735
1883
  const submittedMode = executionSnapshot.mode;
1736
1884
  const submittedRequireReviewing = executionSnapshot.requireReviewing === true;
1737
1885
  const replaceRunId = queuedInput ? '' : runController.resolveRetryReplacement({
1738
- candidate: pendingRetryReplacement,
1739
- activeSessionId: state.activeSessionId,
1740
- runs: state.runs,
1886
+ candidate: submittedRetryReplacement,
1887
+ activeSessionId: submittedSessionId,
1888
+ runs: findSessionById(submittedSessionId)?.runs,
1741
1889
  projectRunSettlement: run => WritebackSettlement.projectRunSettlement(run)
1742
1890
  });
1743
1891
 
@@ -1745,6 +1893,9 @@
1745
1893
  runCancellationController = new AbortController();
1746
1894
  setRunning(true);
1747
1895
  currentRunView = startRunView({
1896
+ sessionId: submittedSessionId,
1897
+ sessionContext: submittedSession,
1898
+ codexThreadId: submittedThreadId,
1748
1899
  task,
1749
1900
  mode: submittedMode,
1750
1901
  model: executionSnapshot.model,
@@ -1756,9 +1907,10 @@
1756
1907
  queueItemId: queuedInput?.id || '',
1757
1908
  replaceRunId
1758
1909
  });
1759
- if (!queuedInput) pendingRetryReplacement = null;
1910
+ if (!queuedInput && pendingRetryReplacement === submittedRetryReplacement) pendingRetryReplacement = null;
1760
1911
  const runSessionId = currentRunView.sessionId;
1761
1912
  let runAuditDraft = null;
1913
+ let localSkillRestorePoint = null;
1762
1914
  try {
1763
1915
  if (queuedInput?.id) {
1764
1916
  await runQueueScheduler?.markExecuting(runSessionId, queuedInput.id, currentRunView.recordId);
@@ -1770,14 +1922,32 @@
1770
1922
  focusFiles: executionSnapshot.focusFiles,
1771
1923
  executionSnapshot
1772
1924
  }));
1925
+ if (submittedWritingStyleScope.accountScopeId !== cachedAccountScopeId
1926
+ || submittedWritingStyleScope.projectId !== getCurrentProjectId()) {
1927
+ throw Object.assign(createRunCancellationError(), {
1928
+ message: 'The active project or account changed before dispatch.'
1929
+ });
1930
+ }
1773
1931
  announceCrossTabRunStart();
1774
1932
  // Binary attachments belong to this submitted turn. The run record has
1775
1933
  // already captured their snapshots, so clear them from the composer
1776
1934
  // together with the submitted text. Persistent @context remains intact.
1777
- clearTaskComposer({ keepAttachments: false });
1935
+ const submittedComposerCleared = !queuedInput && clearTaskComposer({ keepAttachments: false, submission: {
1936
+ ...submittedWritingStyleScope,
1937
+ sessionId: submittedSessionId, task: submittedDraft, attachments: submittedAttachments,
1938
+ skillInvocation: submittedSkillInvocation, selectionContext: submittedPanelState.selectionContext,
1939
+ selectionRevision: submittedSelectionRevision
1940
+ } });
1941
+ if (submittedComposerCleared && submittedSkillInvocation?.scope === 'codex-local'
1942
+ && state.activeSessionId === submittedSessionId && !panel?.querySelector('[data-task]')?.value
1943
+ && !getComposerAttachmentsForRun().length && !composerAttachmentController.getAttachmentByteUsage?.().pending
1944
+ && !getComposerSkillInvocationForRun() && !selectionContextView.capture()) {
1945
+ localSkillRestorePoint = { draftRevision: composerDraftRevision,
1946
+ attachmentRevision: composerAttachmentController.getRevision?.(), selectionRevision: selectionContextView.getRevision?.() };
1947
+ }
1778
1948
  syncComposerSendAvailability(true);
1779
1949
  appendRunEvent({
1780
- title: submittedSkillInvocation?.id === 'skill-installer'
1950
+ title: submittedSkillInvocation?.id === 'skill-installer' && !submittedSkillInvocation.scope
1781
1951
  ? tx('I will use the Codex skill installer for this request.', '我会用 Codex skill installer 处理这个请求。')
1782
1952
  : tx('I will first understand your request, then inspect the relevant Overleaf files.', '我会先理解你的请求,再检查相关 Overleaf 文件。'),
1783
1953
  status: 'running',
@@ -1792,7 +1962,7 @@
1792
1962
  '@context': formatContextItems(executionSnapshot.focusFiles)
1793
1963
  }
1794
1964
  });
1795
- if (submittedSkillInvocation?.id === 'skill-installer') {
1965
+ if (submittedSkillInvocation?.id === 'skill-installer' && !submittedSkillInvocation.scope) {
1796
1966
  await awaitRunStep(runSkillInstallerTask({
1797
1967
  task,
1798
1968
  runSessionId,
@@ -1800,6 +1970,8 @@
1800
1970
  submittedMode,
1801
1971
  submittedCustomInstructions,
1802
1972
  submittedSkillLoadingSettings,
1973
+ submittedEnabledLocalSkillIds,
1974
+ submittedEnabledSkillIds,
1803
1975
  submittedAttachments,
1804
1976
  submittedSkillInvocation,
1805
1977
  executionSnapshot
@@ -1860,6 +2032,7 @@
1860
2032
  project,
1861
2033
  snapshotWarnings,
1862
2034
  focusFiles,
2035
+ focusFilesAreLiteral: true,
1863
2036
  isUsableProjectFileContent: Modules.ProjectFiles.isUsableProjectFileContent
1864
2037
  });
1865
2038
  let restrictToFocusFiles = runController.shouldRestrictWritebackToFocus({ focusFiles, focusedPartialSnapshot });
@@ -1928,8 +2101,7 @@
1928
2101
  }
1929
2102
  }
1930
2103
 
1931
- const activeSession = findSessionById(runSessionId) || getActiveSession(state);
1932
- const codexThreadId = activeSession?.codexThreadId || '';
2104
+ const codexThreadId = submittedThreadId;
1933
2105
 
1934
2106
  // Resolve @compile-log if mentioned in task
1935
2107
  let compileLogContext = null;
@@ -1957,14 +2129,14 @@
1957
2129
  }
1958
2130
  });
1959
2131
  appendRunEvent({
1960
- title: tx(`Compile log unavailable: ${compileLogContext.reason}`, `编译日志不可用:${compileLogContext.reason}`),
2132
+ title: tx(`Compile log unavailable: ${Modules.CompileAdapter.localizeCompileReason(compileLogContext.reason, getLocale())}`, `编译日志不可用:${Modules.CompileAdapter.localizeCompileReason(compileLogContext.reason, getLocale())}`),
1961
2133
  status: 'failed',
1962
2134
  failure: contextFailure
1963
2135
  });
1964
2136
  }
1965
2137
  }
1966
2138
 
1967
- const sensitiveFindings = await awaitRunStep(runSensitivePreflight({
2139
+ let sensitiveFindings = await awaitRunStep(runSensitivePreflight({
1968
2140
  task,
1969
2141
  project,
1970
2142
  rules: getGovernanceRulesForCurrentProject(),
@@ -2001,6 +2173,8 @@
2001
2173
  compileLogContext,
2002
2174
  customInstructions: submittedCustomInstructions,
2003
2175
  skillLoadingSettings: submittedSkillLoadingSettings,
2176
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2177
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2004
2178
  attachments: submittedAttachments,
2005
2179
  skillInvocation: submittedSkillInvocation,
2006
2180
  submittedMode
@@ -2022,6 +2196,25 @@
2022
2196
  fileOverlays = null;
2023
2197
  otWarmStart = false;
2024
2198
  restrictToFocusFiles = staleRetry.restrictToFocusFiles;
2199
+ const retryFindings = await awaitRunStep(runSensitivePreflight({
2200
+ task, project, rules: getGovernanceRulesForCurrentProject(), useExistingMirror: false
2201
+ }));
2202
+ if (retryFindings.blocked) {
2203
+ await finalizeAuditRecord(runAuditDraft, {
2204
+ resultStatus: 'blocked_sensitive', sensitiveFindings: retryFindings.findings,
2205
+ blockedFiles: retryFindings.findings.map(finding => ({
2206
+ path: finding.path || finding.source || 'task', reason: finding.detectorId || 'sensitive'
2207
+ }))
2208
+ });
2209
+ await finishRunView(tx('Blocked: sensitive content review required', '已阻止:需要处理敏感内容'), 'failed');
2210
+ return;
2211
+ }
2212
+ if (retryFindings.findings.length) {
2213
+ sensitiveFindings = {
2214
+ blocked: false, findings: [...sensitiveFindings.findings, ...retryFindings.findings]
2215
+ };
2216
+ runAuditDraft = await updateAuditSensitiveFindings(runAuditDraft, sensitiveFindings.findings);
2217
+ }
2025
2218
  response = await awaitRunStep(sendTrackedCodexRun(buildCodexRunParams({
2026
2219
  task,
2027
2220
  project,
@@ -2034,6 +2227,8 @@
2034
2227
  compileLogContext,
2035
2228
  customInstructions: submittedCustomInstructions,
2036
2229
  skillLoadingSettings: submittedSkillLoadingSettings,
2230
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2231
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2037
2232
  attachments: submittedAttachments,
2038
2233
  skillInvocation: submittedSkillInvocation,
2039
2234
  submittedMode
@@ -2065,6 +2260,8 @@
2065
2260
  compileLogContext,
2066
2261
  customInstructions: submittedCustomInstructions,
2067
2262
  skillLoadingSettings: submittedSkillLoadingSettings,
2263
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2264
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2068
2265
  attachments: submittedAttachments,
2069
2266
  skillInvocation: submittedSkillInvocation,
2070
2267
  submittedMode
@@ -2085,7 +2282,12 @@
2085
2282
  void finalizeAuditRecord(runAuditDraft, { resultStatus: 'cancelled' });
2086
2283
  return;
2087
2284
  }
2088
- const translated = translateRawError(response.error.message, { mode: submittedMode, locale: getLocale() });
2285
+ if (response.error?.code === 'codex_local_skill_unavailable' && localSkillRestorePoint) {
2286
+ restoreRejectedLocalSkillSubmission({ ...submittedWritingStyleScope, sessionId: submittedSessionId,
2287
+ task: submittedDraft, attachments: submittedAttachments, skillInvocation: submittedSkillInvocation,
2288
+ selectionContext: submittedPanelState.selectionContext }, localSkillRestorePoint);
2289
+ }
2290
+ const translated = translateRawError(response.error.message, { mode: submittedMode, locale: getLocale(), code: response.error?.code });
2089
2291
  // Structured FailureReason (§9.7): split bridge-availability from
2090
2292
  // Codex-side errors. `native_connection_failed` / `native_unavailable`
2091
2293
  // / `native_update_required` indicate the bridge itself is missing,
@@ -2097,7 +2299,11 @@
2097
2299
  // steps) existed but were never emitted, so everything collapsed into
2098
2300
  // "no usable result" and pointed users at the wrong recovery.
2099
2301
  const runErrorMessage = String(response.error?.message || '');
2100
- const codexRunFailure = response.error?.code === 'native_execution_interrupted'
2302
+ const codexRunFailure = response.error?.code === 'codex_local_skill_unavailable'
2303
+ ? buildContentFailure('codex_local_skill_unavailable', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2304
+ : translated.failureCode === 'codex_local_skills_failed'
2305
+ ? buildContentFailure('codex_local_skills_failed', { path: 'codex.run' }, { technicalMessage: runErrorMessage })
2306
+ : response.error?.code === 'native_execution_interrupted'
2101
2307
  ? buildContentFailure('native_request_failed', { path: 'codex.run' }, {
2102
2308
  // The host IS installed — the request was interrupted mid-flight.
2103
2309
  // Telling this user to reinstall would be a misdiagnosis.
@@ -2422,6 +2628,8 @@
2422
2628
  submittedMode,
2423
2629
  submittedCustomInstructions,
2424
2630
  submittedSkillLoadingSettings,
2631
+ submittedEnabledLocalSkillIds,
2632
+ submittedEnabledSkillIds,
2425
2633
  submittedAttachments,
2426
2634
  submittedSkillInvocation,
2427
2635
  executionSnapshot
@@ -2430,8 +2638,7 @@
2430
2638
  title: tx('Starting the Codex skill installer.', '正在启动 Codex skill installer。'),
2431
2639
  status: 'running'
2432
2640
  });
2433
- const activeSession = findSessionById(runSessionId) || getActiveSession(state);
2434
- const codexThreadId = activeSession?.codexThreadId || '';
2641
+ const codexThreadId = currentRunView?.codexThreadId || '';
2435
2642
  const params = {
2436
2643
  projectId: getCurrentProjectId(),
2437
2644
  mode: submittedMode,
@@ -2439,21 +2646,23 @@
2439
2646
  model: executionSnapshot.model,
2440
2647
  reasoningEffort: executionSnapshot.reasoningEffort,
2441
2648
  speedTier: executionSnapshot.speedTier,
2442
- session: state.session,
2649
+ session: currentRunView?.sessionContext,
2650
+ clientRunId: currentRunView?.recordId || '',
2651
+ clientSessionId: runSessionId,
2652
+ accountScopeId: currentRunView?.runAccountScopeId || '',
2443
2653
  threadId: codexThreadId || undefined,
2444
2654
  customInstructions: submittedCustomInstructions || undefined,
2445
2655
  loadCodexLocalSkills: submittedSkillLoadingSettings?.loadCodexLocalSkills !== false,
2446
2656
  loadCodexOverleafSkills: submittedSkillLoadingSettings?.loadCodexOverleafSkills !== false,
2657
+ enabledCodexLocalSkillIds: submittedEnabledLocalSkillIds,
2658
+ enabledCodexOverleafSkillIds: submittedEnabledSkillIds,
2447
2659
  attachments: submittedAttachments.length ? submittedAttachments : undefined,
2448
2660
  skillInvocation: submittedSkillInvocation,
2449
2661
  focusFiles: [],
2450
2662
  skipMirrorSync: true
2451
2663
  };
2452
2664
 
2453
- const response = await sendNative({
2454
- method: 'codex.run',
2455
- params
2456
- });
2665
+ const response = await sendTrackedCodexRun(params);
2457
2666
 
2458
2667
  if (!response.ok) {
2459
2668
  if (runCancellationRequested || isRunCancellationError(response.error)) {
@@ -2653,19 +2862,31 @@
2653
2862
  if (currentRunView) {
2654
2863
  return queueComposerInput();
2655
2864
  }
2865
+ if (runSubmissionPending) return;
2866
+ runSubmissionPending = true;
2656
2867
  runTask().catch(error => {
2657
- setRunning(false);
2658
- nativeChannel.clearActiveRequest();
2659
- stopRunElapsedTick();
2660
- currentRunView = null;
2661
- runCancellationController = null;
2868
+ if (!currentRunView) {
2869
+ setRunning(false);
2870
+ nativeChannel.clearActiveRequest();
2871
+ stopRunElapsedTick();
2872
+ runCancellationController = null;
2873
+ }
2662
2874
  console.error('[codex-overleaf] failed to start task', error);
2663
- appendPlainLog(tx(`Could not start Codex task: ${error.message}`, `无法启动 Codex 任务:${error.message}`));
2664
- });
2875
+ appendPlainLog(tx(`Could not start Codex task: ${localizeVisibleReason(error.message)}`, `无法启动 Codex 任务:${localizeVisibleReason(error.message)}`));
2876
+ }).finally(() => { runSubmissionPending = false; });
2665
2877
  }
2666
2878
 
2667
2879
  const runGuidanceController = Modules.RunGuidanceController.create({
2668
- findRunRecord, appendRunEvent, getCurrentRunView: () => currentRunView,
2880
+ findRunRecord, getCurrentRunView: () => getCurrentRunViewForRender(),
2881
+ getActiveSessionId: () => state?.activeSessionId,
2882
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
2883
+ getSessionCreatedAt: sessionId => findSessionById(sessionId)?.createdAt,
2884
+ getRunRoot: target => target.sessionId === state?.activeSessionId
2885
+ ? panel?.querySelector(`[data-run-id="${cssEscape(target.recordId)}"]`) : null,
2886
+ onRecordChanged: target => updateSessionById(target.sessionId, {}),
2887
+ onPresentationError: error => console.warn('[codex-overleaf] guidance presentation failed', error),
2888
+ RunGuidanceView: Modules.RunGuidanceView, tx, maxRunEvents: MAX_RUN_EVENTS,
2889
+ revealGuidance: row => runTimelineView.revealGuidance(row),
2669
2890
  renderRunEvent, cssEscape, bumpUnreadIfDetached
2670
2891
  });
2671
2892
 
@@ -2679,7 +2900,16 @@
2679
2900
  readInputs: readPanelInputs,
2680
2901
  getTask: () => panel?.querySelector('[data-task]')?.value || '',
2681
2902
  hasAttachments: () => getComposerAttachmentsForRun().length > 0,
2682
- capturePayload: () => RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
2903
+ capturePayload: () => ({
2904
+ ...RunExecutionSnapshot.toQueuePayload(captureCurrentExecutionSnapshot()),
2905
+ submissionContext: RunInputQueue.normalizeSubmissionContext({
2906
+ customInstructions: getCustomInstructionsForCurrentProject(),
2907
+ skillLoadingSettings: getSkillLoadingSettings(),
2908
+ skillInvocation: getComposerSkillInvocationForRun(),
2909
+ enabledCodexOverleafSkillIds: getEnabledCodexOverleafSkillIds(),
2910
+ enabledCodexLocalSkillIds: getEnabledCodexLocalSkillIds()
2911
+ })
2912
+ }),
2683
2913
  prepareClaim: () => providerSettingsCoordinator.ensureLoaded(),
2684
2914
  resolveExecutionSnapshot: snapshot => RunExecutionSnapshot.resolveForExecution(snapshot, {
2685
2915
  catalog: providerSettingsCoordinator.getCatalog()
@@ -2698,10 +2928,18 @@
2698
2928
  );
2699
2929
  },
2700
2930
  run: item => runTask({ queuedInput: item }),
2701
- sendSteer: params => sendBackgroundNative({ method: 'codex.steer', params }),
2931
+ getGuideScope: () => ({ projectId: getCurrentProjectId(), accountScopeId: cachedAccountScopeId, generation: spaRouteGeneration }),
2932
+ isGuideCancelled: () => runCancellationRequested || runCancellationController?.signal?.aborted === true,
2933
+ preflightGuide: ({ task, assertCurrent }) => runSensitivePreflight({
2934
+ task, project: { files: [] }, rules: getGovernanceRulesForCurrentProject(),
2935
+ guidance: true, useExistingMirror: false, assertCurrent
2936
+ }),
2937
+ sendSteer: (params, context) => nativeCompatibilityController.sendBackgroundNative({ method: 'codex.steer', params }, context),
2702
2938
  getContainer: () => panel?.querySelector('[data-pending-inputs]'),
2703
2939
  createView: options => PendingInputView.create(options),
2704
2940
  appendEvent: (title, status) => appendRunEvent({ title, status }),
2941
+ previewGuidance: runGuidanceController.preview,
2942
+ clearGuidancePreview: runGuidanceController.clearPreview,
2705
2943
  appendGuidance: runGuidanceController.upsert,
2706
2944
  removeGuidance: runGuidanceController.remove,
2707
2945
  toast: (message, status) => showPluginToast(message, { status }),
@@ -2741,6 +2979,18 @@
2741
2979
  }
2742
2980
 
2743
2981
  function queueComposerInput() {
2982
+ if (getSkillLoadingSettings().loadCodexLocalSkills !== false && !Array.isArray(getCodexLocalSkillsCatalog())) {
2983
+ primeCodexLocalSkillsCatalog({ required: true }).catch(() => {});
2984
+ showPluginToast(tx('Local Codex skills are still loading. Try submitting again when loading finishes.',
2985
+ 'Codex 本地技能仍在加载,请在加载完成后重新提交。'), { status: 'info' });
2986
+ return;
2987
+ }
2988
+ const invocation = getComposerSkillInvocationForRun();
2989
+ if (invocation?.scope === 'codex-local' && !getEnabledCodexLocalSkillIds()?.includes(invocation.id)) {
2990
+ showPluginToast(tx('The selected local Codex skill is unavailable or disabled. Select an enabled skill and try again.',
2991
+ '所选 Codex 本地技能不可用或已关闭,请选择已启用的技能后重试。'), { status: 'failed' });
2992
+ return;
2993
+ }
2744
2994
  return runInputCoordinator?.queueComposerInput();
2745
2995
  }
2746
2996
 
@@ -2790,7 +3040,7 @@
2790
3040
  const nativeResult = results[0];
2791
3041
  const nativeResponse = nativeResult?.status === 'fulfilled' ? nativeResult.value : null;
2792
3042
  if (nativeResponse && !nativeResponse.ok) {
2793
- const message = nativeResponse?.error?.message || tx('native host did not respond', 'native host 没有响应');
3043
+ const message = localizeVisibleReason(nativeResponse?.error?.message || tx('native host did not respond', 'native host 没有响应'));
2794
3044
  appendRunEvent({
2795
3045
  title: tx(`Cancel request was not delivered: ${message}`, `中断请求没有送达:${message}`),
2796
3046
  status: 'failed'
@@ -2832,25 +3082,8 @@
2832
3082
  force: true
2833
3083
  }
2834
3084
  });
2835
- if (response?.ok) {
2836
- const result = response.result || response;
2837
- const released = result.lockReleased === true || result.cancelled === true;
2838
- appendPlainLog(released
2839
- ? tx(
2840
- `Force-released the stuck Codex run for project ${projectKey}. You can retry now.`,
2841
- `已强制释放项目 ${projectKey} 的 Codex 占用,可以重试了。`
2842
- )
2843
- : tx(
2844
- `No stuck run was found for project ${projectKey}.`,
2845
- `没有发现项目 ${projectKey} 上的卡住任务。`
2846
- ));
2847
- return response;
2848
- }
2849
- const message = response?.error?.message || tx('native host did not respond', 'native host 没有响应');
2850
- appendPlainLog(tx(
2851
- `Force-release request was not delivered: ${message}`,
2852
- `强制释放请求没有送达:${message}`
2853
- ));
3085
+ appendPlainLog(Modules.RunRecoveryActions.formatForceCancelOutcome(response, tx));
3086
+ if (response?.ok !== true && response?.error?.message) appendPlainLog(localizeVisibleReason(response.error.message));
2854
3087
  return response;
2855
3088
  }
2856
3089
 
@@ -2954,6 +3187,12 @@
2954
3187
  .filter(id => id && isCodexOverleafSkillEnabled(id));
2955
3188
  }
2956
3189
 
3190
+ function getEnabledCodexLocalSkillIds() {
3191
+ if (getSkillLoadingSettings().loadCodexLocalSkills === false) return [];
3192
+ const skills = getCodexLocalSkillsCatalog();
3193
+ return Array.isArray(skills) ? skills.filter(skill => isCodexLocalSkillEnabled(skill.id, skill)).map(skill => skill.id) : undefined;
3194
+ }
3195
+
2957
3196
  function buildCodexRunParams({
2958
3197
  task,
2959
3198
  project,
@@ -2966,12 +3205,16 @@
2966
3205
  compileLogContext,
2967
3206
  customInstructions,
2968
3207
  skillLoadingSettings,
3208
+ enabledCodexOverleafSkillIds,
3209
+ enabledCodexLocalSkillIds,
2969
3210
  attachments,
2970
3211
  skillInvocation,
2971
3212
  submittedMode
2972
3213
  } = {}) {
2973
3214
  const executionSnapshot = currentRunView?.executionSnapshot || captureCurrentExecutionSnapshot();
2974
- const submittedState = RunExecutionSnapshot.applyToState(state, executionSnapshot);
3215
+ const submittedState = RunExecutionSnapshot.applyToState({
3216
+ ...state, session: currentRunView?.sessionContext || state.session
3217
+ }, executionSnapshot);
2975
3218
  const params = runController.buildCodexRunParams({
2976
3219
  currentProjectId: getCurrentProjectId(),
2977
3220
  state: submittedState,
@@ -2991,7 +3234,9 @@
2991
3234
  ? getSkillLoadingSettings()
2992
3235
  : { loadCodexLocalSkills: true, loadCodexOverleafSkills: true })
2993
3236
  : skillLoadingSettings,
2994
- enabledCodexOverleafSkillIds: getEnabledCodexOverleafSkillIds(),
3237
+ enabledCodexOverleafSkillIds: enabledCodexOverleafSkillIds === undefined
3238
+ ? getEnabledCodexOverleafSkillIds() : enabledCodexOverleafSkillIds,
3239
+ enabledCodexLocalSkillIds,
2995
3240
  attachments,
2996
3241
  skillInvocation,
2997
3242
  compileLogContext,
@@ -3009,11 +3254,23 @@
3009
3254
  }
3010
3255
 
3011
3256
  async function sendTrackedCodexRun(params) {
3012
- const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run');
3257
+ const owner = currentRunView;
3258
+ const signal = runCancellationController?.signal;
3259
+ const assertCurrent = () => {
3260
+ if (signal?.aborted || (owner && owner !== currentRunView)) throw createRunCancellationError();
3261
+ throwIfRunCancellationRequested();
3262
+ if (owner && (owner.runProjectId !== getCurrentProjectId()
3263
+ || owner.runAccountScopeId !== cachedAccountScopeId)) {
3264
+ throw Object.assign(createRunCancellationError(), {
3265
+ message: 'The active project or account changed before dispatch.'
3266
+ });
3267
+ }
3268
+ };
3269
+ const compatibilityGate = await nativeCompatibilityController.ensureForMethod('codex.run', { assertCurrent });
3270
+ assertCurrent();
3013
3271
  if (!compatibilityGate.ok) {
3014
3272
  return compatibilityGate.response;
3015
3273
  }
3016
- throwIfRunCancellationRequested();
3017
3274
  const tracked = nativeChannel.sendNativeTracked(nativeCompatibilityController.attachEvidence({
3018
3275
  method: 'codex.run',
3019
3276
  params
@@ -3044,6 +3301,7 @@
3044
3301
  const projectKey = getCurrentProjectId();
3045
3302
  const recovery = await activeTurnControl.recoverJournals({
3046
3303
  projectKey,
3304
+ accountScopeId: cachedAccountScopeId,
3047
3305
  requireOwnerLost: Modules.StorageDb.sharedSessionsEnabled?.() === true,
3048
3306
  normalizeInterruptedRun: record => Object.assign(record, Modules.SessionState.normalizeRuns([record], {
3049
3307
  restoreRunningRuns: true,
@@ -3091,7 +3349,7 @@
3091
3349
  maxEvents: MAX_RUN_EVENTS
3092
3350
  });
3093
3351
  if (recovery.changed) {
3094
- await saveState();
3352
+ if (await saveState({ recoveryRuns: recovery.recoveredRuns }) !== true) return;
3095
3353
  for (const requestId of recovery.acknowledgeIds) {
3096
3354
  await activeTurnControl.acknowledge(requestId).catch(() => false);
3097
3355
  }
@@ -3103,11 +3361,17 @@
3103
3361
  if (!StorageDb || !AuditRecords) {
3104
3362
  return null;
3105
3363
  }
3364
+ // Audit follows the captured run owner even if the visible page changes.
3365
+ // Missing owner identity must never be filled with a later account.
3366
+ const accountScopeId = currentRunView ? currentRunView.runAccountScopeId : cachedAccountScopeId;
3367
+ const projectId = currentRunView ? currentRunView.runProjectId : getCurrentProjectId();
3368
+ if (!accountScopeId || !projectId) return null;
3106
3369
  const executionSnapshot = input.executionSnapshot
3107
3370
  || currentRunView?.executionSnapshot
3108
3371
  || captureCurrentExecutionSnapshot();
3109
3372
  const draft = AuditRecords.buildAuditDraftRecord({
3110
- projectId: getCurrentProjectId(),
3373
+ accountScopeId,
3374
+ projectId,
3111
3375
  sessionId: input.sessionId || '',
3112
3376
  turnId: currentRunView?.recordId || '',
3113
3377
  mode: input.mode || executionSnapshot.mode,
@@ -3142,8 +3406,10 @@
3142
3406
  return draft;
3143
3407
  }
3144
3408
  const record = StorageDb.buildAuditLogRecord
3145
- ? StorageDb.buildAuditLogRecord({ ...draft, ...updates, id: draft.id, completedAt: updates.completedAt || draft.completedAt || '' })
3146
- : { ...draft, ...updates };
3409
+ ? StorageDb.buildAuditLogRecord({ ...draft, ...updates, id: draft.id,
3410
+ accountScopeId: draft.accountScopeId, projectId: draft.projectId,
3411
+ completedAt: updates.completedAt || draft.completedAt || '' })
3412
+ : { ...draft, ...updates, id: draft.id, accountScopeId: draft.accountScopeId, projectId: draft.projectId };
3147
3413
  return StorageDb.putRecord('auditLogs', record).then(() => record).catch(() => draft);
3148
3414
  }
3149
3415
 
@@ -3173,121 +3439,10 @@
3173
3439
  }
3174
3440
 
3175
3441
  async function runSensitivePreflight(options) {
3176
- options = options || {};
3177
- const { task, project, rules, useExistingMirror = false } = options;
3178
- const normalizedRules = normalizeGovernanceRules(rules);
3179
- if (normalizedRules.sensitiveCheckEnabled === false) {
3180
- return { blocked: false, findings: [] };
3181
- }
3182
- if (!SensitiveScan?.scanSensitiveInputs && !useExistingMirror) {
3183
- return { blocked: false, findings: [] };
3184
- }
3185
- let findings = SensitiveScan?.scanSensitiveInputs
3186
- ? SensitiveScan.scanSensitiveInputs({
3187
- task,
3188
- files: (project?.files || []).filter(isTextSnapshotFile)
3189
- })
3190
- : [];
3191
- if (useExistingMirror) {
3192
- findings = dedupeSensitiveFindings([
3193
- ...findings,
3194
- ...await scanNativeMirrorSensitiveFindings()
3195
- ]);
3196
- }
3197
- if (!findings.length) {
3198
- return { blocked: false, findings: [] };
3199
- }
3200
- appendRunEvent({
3201
- title: tx(
3202
- `Sensitive content check found ${findings.length} item(s).`,
3203
- `敏感内容检查发现 ${findings.length} 项。`
3204
- ),
3205
- status: 'failed',
3206
- detail: findings.slice(0, 8).map(finding => ({
3207
- [tr('detailFile')]: finding.path || finding.source || 'task',
3208
- [tr('detailReason')]: finding.detectorId,
3209
- [tx('Preview', '预览')]: finding.preview || '[REDACTED]'
3210
- }))
3211
- });
3212
- if (normalizedRules.sensitiveConfirmAllowed === true) {
3213
- const approved = await showPluginConfirm({
3214
- title: tr('sensitiveConfirmTitle'),
3215
- message: formatSensitiveConfirmMessage(findings),
3216
- confirmLabel: tr('sensitiveConfirmRun'),
3217
- cancelLabel: tr('confirmDefaultCancel'),
3218
- destructive: true
3219
- });
3220
- return { blocked: !approved, findings };
3221
- }
3222
- appendCompletionReport({
3223
- conclusion: tx(
3224
- 'This run was blocked before Codex received project context because sensitive content was detected.',
3225
- '检测到敏感内容,本轮已在向 Codex 发送项目上下文前阻止。'
3226
- ),
3227
- status: 'blocked',
3228
- operations: [],
3229
- applyResults: [],
3230
- nextStep: tx(
3231
- 'Remove the sensitive content, narrow @context, or enable explicit sensitive confirmation in Project Settings.',
3232
- '请移除敏感内容、缩小 @context,或在项目设置里允许敏感内容显式确认。'
3233
- )
3234
- });
3235
- return { blocked: true, findings };
3236
- }
3237
-
3238
- function formatSensitiveConfirmMessage(findings = []) {
3239
- const visible = findings.slice(0, 8).map(finding => {
3240
- const path = finding.path || finding.source || 'task';
3241
- return `${path}: ${finding.detectorId || 'sensitive'} (${finding.preview || '[REDACTED]'})`;
3242
- });
3243
- return [
3244
- tr('sensitiveConfirmMessage'),
3245
- '',
3246
- ...visible,
3247
- findings.length > visible.length ? tx(`${findings.length - visible.length} more finding(s).`, `另有 ${findings.length - visible.length} 项。`) : ''
3248
- ].filter(Boolean).join('\n');
3249
- }
3250
-
3251
- async function scanNativeMirrorSensitiveFindings() {
3252
- try {
3253
- const response = await sendBackgroundNative({
3254
- method: 'mirror.scanSensitive',
3255
- params: { projectId: getCurrentProjectId() }
3256
- });
3257
- if (response?.ok) {
3258
- return Array.isArray(response.result?.findings) ? response.result.findings : [];
3259
- }
3260
- return [{
3261
- detectorId: 'mirror-sensitive-scan-unavailable',
3262
- source: 'local-mirror',
3263
- preview: response?.error?.message || 'Could not scan the local mirror before Codex runs.'
3264
- }];
3265
- } catch (error) {
3266
- return [{
3267
- detectorId: 'mirror-sensitive-scan-unavailable',
3268
- source: 'local-mirror',
3269
- preview: error?.message || 'Could not scan the local mirror before Codex runs.'
3270
- }];
3271
- }
3272
- }
3273
-
3274
- function dedupeSensitiveFindings(findings = []) {
3275
- const seen = new Set();
3276
- const deduped = [];
3277
- for (const finding of findings || []) {
3278
- const key = [
3279
- finding?.detectorId || '',
3280
- finding?.source || '',
3281
- finding?.path || '',
3282
- finding?.preview || ''
3283
- ].join('\u0000');
3284
- if (seen.has(key)) {
3285
- continue;
3286
- }
3287
- seen.add(key);
3288
- deduped.push(finding);
3289
- }
3290
- return deduped;
3442
+ return Modules.SensitivePreflight.create({
3443
+ SensitiveScan, normalizeGovernanceRules, isTextSnapshotFile, sendBackgroundNative,
3444
+ getCurrentProjectId, appendRunEvent, appendCompletionReport, showPluginConfirm, tr, tx
3445
+ }).run(options);
3291
3446
  }
3292
3447
 
3293
3448
  function appendRunCancelledReport() {
@@ -3354,6 +3509,7 @@
3354
3509
  }
3355
3510
 
3356
3511
  function handleTaskInput() {
3512
+ composerDraftRevision += 1;
3357
3513
  autosizeTaskTextarea();
3358
3514
  syncComposerSendAvailability();
3359
3515
  renderPendingInputs();
@@ -3376,10 +3532,8 @@
3376
3532
  subtitle: tr('slashInstallSkillSubtitle')
3377
3533
  }
3378
3534
  ];
3379
- if (getSkillLoadingSettings().loadCodexOverleafSkills === false) {
3380
- return commands;
3381
- }
3382
- for (const skill of slashCodexOverleafSkills) {
3535
+ const settings = getSkillLoadingSettings();
3536
+ for (const skill of settings.loadCodexOverleafSkills === false ? [] : slashCodexOverleafSkills) {
3383
3537
  const id = String(skill?.id || '').trim();
3384
3538
  if (!isSafeSkillId(id)) {
3385
3539
  continue;
@@ -3395,13 +3549,28 @@
3395
3549
  scope: 'codex-overleaf',
3396
3550
  skillId: id,
3397
3551
  title,
3398
- subtitle: tr('slashUseSkillSubtitle')
3552
+ subtitle: tr('slashUseOverleafSkillSubtitle')
3399
3553
  });
3400
3554
  }
3555
+ const localSkills = settings.loadCodexLocalSkills === false ? [] : (getCodexLocalSkillsCatalog() || []);
3556
+ for (const skill of localSkills) {
3557
+ if (!isSafeSkillId(skill.id) || !isCodexLocalSkillEnabled(skill.id, skill)) continue;
3558
+ const sameName = localSkills.filter(other => String(other.title || other.name || other.id) === String(skill.title || skill.name || skill.id)).length > 1;
3559
+ commands.push({ id: `local-skill:${skill.id}`, kind: 'codex-local-skill', scope: 'codex-local', skillId: skill.id,
3560
+ title: String(skill.title || skill.name || skill.id).trim().slice(0, 80),
3561
+ searchName: String(skill.name || ''),
3562
+ subtitle: tr('slashUseLocalSkillSubtitle') + (skill.sourceKind === 'plugin' && skill.sourceLabel ? ' · ' + skill.sourceLabel : '')
3563
+ + (sameName ? ' · ' + (skill.sourceHint || skill.id.slice(-8)) : '') });
3564
+ }
3401
3565
  return commands;
3402
3566
  }
3403
3567
 
3404
3568
  async function refreshCodexOverleafSkillsForSlashMenu() {
3569
+ if (getSlashTrigger() && getSkillLoadingSettings().loadCodexLocalSkills !== false) {
3570
+ primeCodexLocalSkillsCatalog().catch(() => {
3571
+ showPluginToast(tx('Could not load local Codex skills. Try again.', '无法加载 Codex 本地技能,请重试。'), { status: 'failed' });
3572
+ });
3573
+ }
3405
3574
  if (slashCodexOverleafSkillsLoaded || slashCodexOverleafSkillsLoading) {
3406
3575
  return;
3407
3576
  }
@@ -3435,7 +3604,8 @@
3435
3604
  const commands = getSlashCommands().filter(command => {
3436
3605
  return !query
3437
3606
  || command.title.toLowerCase().includes(query)
3438
- || command.id.toLowerCase().includes(query);
3607
+ || command.id.toLowerCase().includes(query)
3608
+ || String(command.searchName || '').toLowerCase().includes(query);
3439
3609
  });
3440
3610
  renderSlashMenu(commands);
3441
3611
  return;
@@ -3650,6 +3820,17 @@
3650
3820
  renderedSlashCommands = new Map(commands.map(command => [command.id, command]));
3651
3821
  menu.dataset.activeIndex = '0';
3652
3822
  menu.hidden = false;
3823
+ menu.scrollTop = 0;
3824
+ updateSlashMenuLayout();
3825
+ }
3826
+
3827
+ function updateSlashMenuLayout() {
3828
+ const menu = panel?.querySelector('[data-slash-menu]');
3829
+ const composer = panel?.querySelector('[data-composer-form]');
3830
+ if (!menu || menu.hidden || !composer) return;
3831
+ const visibleTop = Math.max(0, root.visualViewport?.offsetTop || 0, panel.getBoundingClientRect().top);
3832
+ const available = Math.max(0, Math.floor(composer.getBoundingClientRect().top - visibleTop - 12));
3833
+ menu.style.setProperty('--codex-slash-menu-space', `${available}px`);
3653
3834
  }
3654
3835
 
3655
3836
  function handleSlashMenuClick(event) {
@@ -3708,6 +3889,18 @@
3708
3889
  buttons.forEach((button, buttonIndex) => {
3709
3890
  button.dataset.active = buttonIndex === index ? 'true' : 'false';
3710
3891
  });
3892
+ const active = buttons[index];
3893
+ if (!active || !menu.clientHeight) return;
3894
+ const viewport = menu.getBoundingClientRect();
3895
+ const item = active.getBoundingClientRect();
3896
+ const top = viewport.top + menu.clientTop;
3897
+ const bottom = top + menu.clientHeight;
3898
+ // Scroll only this popup; scrollIntoView may move the chat or editor too.
3899
+ if (item.height > menu.clientHeight || item.top < top) {
3900
+ menu.scrollTop += item.top - top;
3901
+ } else if (item.bottom > bottom) {
3902
+ menu.scrollTop += item.bottom - bottom;
3903
+ }
3711
3904
  }
3712
3905
 
3713
3906
  function selectSlashCommand(commandId) {
@@ -3729,7 +3922,7 @@
3729
3922
  activateSkillInstallerComposerContext();
3730
3923
  return;
3731
3924
  }
3732
- if (command.kind === 'codex-overleaf-skill') {
3925
+ if (command.kind === 'codex-overleaf-skill' || command.kind === 'codex-local-skill') {
3733
3926
  activateCodexOverleafSkillComposerContext(command);
3734
3927
  }
3735
3928
  }
@@ -3756,6 +3949,7 @@
3756
3949
  }
3757
3950
 
3758
3951
  function activateSkillInstallerComposerContext() {
3952
+ composerDraftRevision += 1;
3759
3953
  composerSkillInvocation = {
3760
3954
  id: 'skill-installer',
3761
3955
  title: tr('skillInstallerComposerLabel')
@@ -3766,6 +3960,7 @@
3766
3960
  }
3767
3961
 
3768
3962
  function activateCodexOverleafSkillComposerContext(command) {
3963
+ composerDraftRevision += 1;
3769
3964
  const id = String(command?.skillId || '').trim();
3770
3965
  if (!isSafeSkillId(id)) {
3771
3966
  return;
@@ -3773,7 +3968,7 @@
3773
3968
  composerSkillInvocation = {
3774
3969
  id,
3775
3970
  title: String(command?.title || id).trim().slice(0, 80) || id,
3776
- scope: 'codex-overleaf'
3971
+ scope: command.scope === 'codex-local' ? 'codex-local' : 'codex-overleaf'
3777
3972
  };
3778
3973
  renderComposerSkillInvocation();
3779
3974
  const input = panel?.querySelector('[data-task]');
@@ -3781,6 +3976,7 @@
3781
3976
  }
3782
3977
 
3783
3978
  function clearComposerSkillInvocation() {
3979
+ composerDraftRevision += 1;
3784
3980
  composerSkillInvocation = null;
3785
3981
  renderComposerSkillInvocation();
3786
3982
  panel?.querySelector('[data-task]')?.focus?.();
@@ -3791,7 +3987,7 @@
3791
3987
  if (!normalized) {
3792
3988
  return null;
3793
3989
  }
3794
- if (normalized.id === 'skill-installer') {
3990
+ if (normalized.id === 'skill-installer' && !normalized.scope) {
3795
3991
  return {
3796
3992
  id: 'skill-installer',
3797
3993
  title: tr('skillInstallerComposerLabel')
@@ -3806,13 +4002,9 @@
3806
4002
  return null;
3807
4003
  }
3808
4004
  const title = String(value?.title || id).trim().slice(0, 80) || id;
3809
- if (id === 'skill-installer') {
3810
- return { id, title };
3811
- }
3812
- if (value?.scope !== 'codex-overleaf') {
3813
- return null;
3814
- }
3815
- return { id, title, scope: 'codex-overleaf' };
4005
+ if (id === 'skill-installer' && (!value?.scope || value.scope === 'builtin')) return { id, title };
4006
+ if (!['codex-overleaf', 'codex-local'].includes(value?.scope)) return null;
4007
+ return { id, title, scope: value.scope };
3816
4008
  }
3817
4009
 
3818
4010
  function isSafeSkillId(id) {
@@ -3833,7 +4025,7 @@
3833
4025
  }
3834
4026
  const clear = context.querySelector('[data-composer-skill-clear]');
3835
4027
  if (clear) {
3836
- const clearLabel = active?.id === 'skill-installer'
4028
+ const clearLabel = active?.id === 'skill-installer' && !active.scope
3837
4029
  ? tr('skillInstallerComposerClear')
3838
4030
  : tr('composerSkillClear');
3839
4031
  clear.title = clearLabel;
@@ -3996,19 +4188,11 @@
3996
4188
  // it from cross-project queries (spec §5.2 storage rules).
3997
4189
  window.codexOverleafDeriveAccountScopeId = () => cachedAccountScopeId;
3998
4190
  var sharedSessionViewSync = Modules.SharedSessionViewSync.install({
3999
- Bridge: Modules.SharedSessionBridge,
4000
- StorageDb: Modules.StorageDb,
4001
- getScope: () => ({
4002
- accountScopeId: cachedAccountScopeId,
4003
- projectId: getCurrentProjectId(),
4004
- generation: spaRouteGeneration
4005
- }),
4006
- getState: () => state,
4191
+ Bridge: Modules.SharedSessionBridge, StorageDb: Modules.StorageDb,
4192
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId(), generation: spaRouteGeneration }),
4193
+ getState: () => state, setState: next => { state = next; },
4007
4194
  isBusy: () => Boolean(readLiveRunViewForSaveStateGuard() || document.querySelector('dialog[open]')),
4008
- apply: sessions => {
4009
- state = normalizePanelState({ ...state, sessions }, { restoreRunningRuns: false });
4010
- applyStateToPanel();
4011
- },
4195
+ normalize: normalizePanelState, render: applyStateToPanel,
4012
4196
  notice: error => appendStorageNoticeOnce('shared-history-refresh', tx(
4013
4197
  `Could not refresh shared history: ${error.message}`,
4014
4198
  `共享会话历史刷新失败:${error.message}`
@@ -4092,7 +4276,7 @@
4092
4276
  chromeApi: chrome, getState: () => state, setState: next => { state = next; }, getPanel: () => panel,
4093
4277
  applyTheme: applyPanelTheme, applyLocale: () => { applyLocaleToPanel(); refreshDashboardSoon(); },
4094
4278
  onPreloadChange: enabled => contextTrayController.scheduleContextPrefetch({ enabled }),
4095
- onSkillsChange: () => { renderLocalSkillList(); updateSkillsEntrySummary(); },
4279
+ onSkillsChange: () => { renderLocalSkillList(); updateSkillsEntrySummary(); updateSlashMenuForTaskInput(); },
4096
4280
  onError: error => showPluginToast(tx(
4097
4281
  `Global settings could not be saved or loaded: ${error.message}. Please retry.`,
4098
4282
  `全局设置未能保存或读取:${error.message}。请重试。`), { status: 'failed', sticky: true })
@@ -4108,6 +4292,7 @@
4108
4292
  storageKey = getProjectStorageKey(LEGACY_STORAGE_KEY, window.location.href);
4109
4293
  const reloaded = await loadStoredState();
4110
4294
  state = normalizeLoadedPanelState(reloaded);
4295
+ await getRunReviewController().recoverInterruptedUndoStatuses();
4111
4296
  applyStateToPanel();
4112
4297
  } catch (_error) {
4113
4298
  // Reload failures fall back to the in-memory state; the next saveState
@@ -4169,6 +4354,7 @@
4169
4354
  function leaveActiveProject(newId) {
4170
4355
  const prevId = activeProjectId;
4171
4356
  activeProjectId = newId; // null for non-project URLs (spec §5.7.1)
4357
+ getComposerDraftPersistence().syncScope();
4172
4358
  if (!prevId || prevId === newId) {
4173
4359
  return;
4174
4360
  }
@@ -4209,6 +4395,7 @@
4209
4395
  // project derivation: account menu is global chrome and should be
4210
4396
  // readable on per-project URLs too).
4211
4397
  await refreshAccountScopeId();
4398
+ getComposerDraftPersistence().syncScope();
4212
4399
  if (generation !== spaRouteGeneration || pathname !== window.location.pathname) {
4213
4400
  return;
4214
4401
  }
@@ -4437,7 +4624,7 @@
4437
4624
  const applied = await applyTaskOperations(project, operations);
4438
4625
  applyResults.push(applied);
4439
4626
  appendApplyResult(applied);
4440
- recordUndoFromApply(project, applied);
4627
+ await recordUndoFromApply(project, applied);
4441
4628
  const approved = await showPluginConfirm({
4442
4629
  title: tx('Confirm delete plan?', '确认删除计划?'),
4443
4630
  message: formatDeletePlan(result.deletePlan || []),
@@ -4451,7 +4638,7 @@
4451
4638
  const deleteApplied = await applyTaskOperations(project, pendingOperations, { allowHighRisk: true });
4452
4639
  applyResults.push(deleteApplied);
4453
4640
  appendApplyResult(deleteApplied);
4454
- recordUndoFromApply(project, deleteApplied);
4641
+ await recordUndoFromApply(project, deleteApplied);
4455
4642
  appendLog(tx('Deleted files after confirmation.', '已按确认删除文件。'));
4456
4643
  const summaryLine = appendChangeSummary({
4457
4644
  notes,
@@ -4504,7 +4691,7 @@
4504
4691
  appendOperationsPreview(operations, tx('Preparing to write', '准备写入'));
4505
4692
  const applied = await applyTaskOperations(project, operations);
4506
4693
  appendApplyResult(applied);
4507
- recordUndoFromApply(project, applied);
4694
+ await recordUndoFromApply(project, applied);
4508
4695
  appendLog(mode === 'auto' ? tx('Auto write task completed.', '自动写入任务完成。') : tx('Task completed.', '任务完成。'));
4509
4696
  const summaryLine = appendChangeSummary({
4510
4697
  notes,
@@ -4529,6 +4716,7 @@
4529
4716
  }
4530
4717
 
4531
4718
  async function applyTaskOperations(project, operations, options = {}) {
4719
+ const target = currentRunView;
4532
4720
  const runRequireReviewing = typeof options.requireReviewing === 'boolean'
4533
4721
  ? options.requireReviewing
4534
4722
  : state.requireReviewing === true;
@@ -4538,7 +4726,13 @@
4538
4726
  title: tr('undoNoWritesTitle'),
4539
4727
  status: 'completed'
4540
4728
  });
4541
- } else {
4729
+ }
4730
+ const reviewing = await ensureReviewingBeforeWrite(partitioned.safe, { requireReviewing: runRequireReviewing });
4731
+ if (!reviewing.ok) {
4732
+ return buildReviewingBlockedApplyResult(partitioned.safe, reviewing, partitioned.skipped);
4733
+ }
4734
+ if (partitioned.safe.length) await stageWritebackRecovery(partitioned.safe, target, runRequireReviewing, project);
4735
+ if (partitioned.safe.length) {
4542
4736
  appendRunEvent({
4543
4737
  title: tx('Saved a recoverable version before this run writes.', '已保存本轮写入前的可恢复版本。'),
4544
4738
  status: 'completed',
@@ -4547,10 +4741,7 @@
4547
4741
  }
4548
4742
  });
4549
4743
  }
4550
- const reviewing = await ensureReviewingBeforeWrite(partitioned.safe, { requireReviewing: runRequireReviewing });
4551
- if (!reviewing.ok) {
4552
- return buildReviewingBlockedApplyResult(partitioned.safe, reviewing, partitioned.skipped);
4553
- }
4744
+
4554
4745
  const applied = partitioned.safe.length
4555
4746
  ? await callPageBridge('applyOperations', {
4556
4747
  operations: partitioned.safe,
@@ -4558,7 +4749,7 @@
4558
4749
  requireReviewing: runRequireReviewing,
4559
4750
  requireEditing: !runRequireReviewing,
4560
4751
  // Welcome-panel + write-guard:
4561
- runProjectId: currentRunView?.runProjectId || ''
4752
+ runProjectId: target?.runProjectId || ''
4562
4753
  })
4563
4754
  : { ok: true, applied: [], skipped: [] };
4564
4755
 
@@ -4962,7 +5153,7 @@
4962
5153
  focusFiles: getActiveFocusFiles()
4963
5154
  });
4964
5155
  const sanitizedProject = sanitizeRunProjectSnapshot(project);
4965
- if (sanitizedProject?.capabilities?.fullProjectSnapshot) {
5156
+ if (sanitizedProject?.capabilities?.fullProjectSnapshot || sanitizedProject?.capabilities?.pathIdentityComplete === false) {
4966
5157
  return sanitizedProject;
4967
5158
  }
4968
5159
  const pageStateProject = await callPageBridge('getProjectSnapshot', {
@@ -4983,25 +5174,37 @@
4983
5174
  }
4984
5175
 
4985
5176
  function sanitizeRunProjectSnapshot(project) {
4986
- if (!project || typeof project !== 'object') {
4987
- return project;
4988
- }
4989
- const activePath = normalizeSnapshotPath(project.activePath || '');
5177
+ if (!project || typeof project !== 'object') return project;
5178
+ const normalize = Modules.ProjectFiles.normalizeSafeProjectPath;
4990
5179
  const files = Array.isArray(project.files) ? project.files : [];
4991
- const filePathSet = new Set(files.map(file => normalizeSnapshotPath(file?.path || '')).filter(Boolean));
4992
- const activePathKnown = activePath && filePathSet.has(activePath);
4993
- if (!activePath || activePathKnown) {
4994
- return project;
5180
+ const counts = new Map();
5181
+ for (const file of files) {
5182
+ const path = normalize(file?.path);
5183
+ if (path) counts.set(path, (counts.get(path) || 0) + 1);
4995
5184
  }
5185
+ const skipped = [];
5186
+ const safeFiles = files.filter(file => {
5187
+ const path = normalize(file?.path);
5188
+ if (path && counts.get(path) === 1) return true;
5189
+ skipped.push({ path: typeof file?.path === 'string' ? file.path : '',
5190
+ reason: path ? 'snapshot_path_duplicate' : 'snapshot_path_invalid' });
5191
+ return false;
5192
+ });
5193
+ const activePath = normalize(project.activePath || '');
5194
+ const activePathKnown = activePath && safeFiles.some(file => file.path === activePath);
5195
+ const rejectActive = Boolean(project.activePath && !activePathKnown);
5196
+ if (!skipped.length && !rejectActive) return project;
4996
5197
  return {
4997
5198
  ...project,
4998
- activePath: '',
4999
- files: files.map(file => file?.active ? { ...file, active: false } : file),
5199
+ activePath: rejectActive ? '' : project.activePath,
5200
+ files: safeFiles.map(file => rejectActive && file?.active ? { ...file, active: false } : file),
5000
5201
  capabilities: {
5001
5202
  ...(project.capabilities || {}),
5203
+ ...(skipped.length ? { fullProjectSnapshot: false, contentComplete: false, pathIdentityComplete: false,
5204
+ skipped: [...(project.capabilities?.skipped || []), ...skipped] } : {}),
5002
5205
  diagnostics: {
5003
5206
  ...(project.capabilities?.diagnostics || {}),
5004
- rejectedActivePath: project.activePath
5207
+ ...(rejectActive ? { rejectedActivePath: project.activePath } : {})
5005
5208
  }
5006
5209
  }
5007
5210
  };
@@ -5087,11 +5290,8 @@
5087
5290
  }
5088
5291
 
5089
5292
  function normalizeSnapshotPath(path) {
5090
- return String(path || '')
5091
- .replace(/^@file:/i, '')
5092
- .replace(/\\/g, '/')
5093
- .trim()
5094
- .replace(/^\/+/, '');
5293
+ // Snapshot/overlay/focus arrays already carry canonical names from file chips.
5294
+ return Modules.ProjectFiles.normalizeSafeProjectPath(path);
5095
5295
  }
5096
5296
 
5097
5297
  function mergeProjectWithSyncChangeBaseFiles(project = {}, syncChanges = []) {
@@ -5303,8 +5503,10 @@
5303
5503
  legacyKey,
5304
5504
  accountScopeId
5305
5505
  );
5506
+ const composerAttachments = await loadComposerAttachmentsForProject(accountScopeId, projectId);
5306
5507
 
5307
5508
  return {
5509
+ composerAttachments,
5308
5510
  model: prefs.model || '', providerId: prefs.providerId || 'builtin',
5309
5511
  reasoningEffort: prefs.reasoningEffort || '',
5310
5512
  speedTier: prefs.speedTier || '',
@@ -5316,6 +5518,7 @@
5316
5518
  loadCodexLocalSkills: prefs.loadCodexLocalSkills !== false,
5317
5519
  loadCodexOverleafSkills: prefs.loadCodexOverleafSkills !== false,
5318
5520
  codexOverleafSkillEnabled: prefs.codexOverleafSkillEnabled || {},
5521
+ codexLocalSkillEnabled: prefs.codexLocalSkillEnabled || {},
5319
5522
  experimentalOtByProject: prefs.experimentalOtByProject || {},
5320
5523
  customInstructionsByProject: prefs.customInstructionsByProject || {},
5321
5524
  governanceRulesByProject: normalizeGovernanceRulesByProject(prefs.governanceRulesByProject),
@@ -5328,6 +5531,8 @@
5328
5531
  focusFiles: session.focusFiles || [], selectionContext: session.selectionContext?.projectId === projectId ? session.selectionContext : null,
5329
5532
  projectReferenceFiles: session.projectReferenceFiles || [],
5330
5533
  codexThreadId: session.codexThreadId || '',
5534
+ forkedFromSessionId: session.forkedFromSessionId || '',
5535
+ forkedFromRunId: session.forkedFromRunId || '',
5331
5536
  createdAt: session.createdAt,
5332
5537
  updatedAt: session.updatedAt,
5333
5538
  runs: Array.isArray(session.runs) ? session.runs : [],
@@ -5368,6 +5573,7 @@
5368
5573
  loadCodexLocalSkills: fallback.loadCodexLocalSkills !== false,
5369
5574
  loadCodexOverleafSkills: fallback.loadCodexOverleafSkills !== false,
5370
5575
  codexOverleafSkillEnabled: fallback.codexOverleafSkillEnabled || {},
5576
+ codexLocalSkillEnabled: fallback.codexLocalSkillEnabled || {},
5371
5577
  experimentalOtByProject: fallback.experimentalOtByProject || {},
5372
5578
  customInstructionsByProject: fallback.customInstructionsByProject || {},
5373
5579
  governanceRulesByProject: normalizeGovernanceRulesByProject(fallback.governanceRulesByProject),
@@ -5401,30 +5607,16 @@
5401
5607
  try {
5402
5608
  const StorageDb = Modules.StorageDb;
5403
5609
  const Migration = Modules.StorageMigration;
5404
- // Welcome-panel + write-guard:
5405
- // post-navigation persistence gate. The normal URL-derived projectId
5406
- // belongs to the route the user is *currently looking at*. When a run
5407
- // is in flight on a different project (mid-run SPA navigation), saving
5408
- // under that URL-derived id would pollute another project's Recent-
5409
- // projects entry and surface bogus disabled rows. Two safety lanes:
5410
- // 1. `options.projectIdOverride` lets a caller that legitimately
5411
- // needs to persist to a non-active project supply the projectId
5412
- // explicitly (the only such caller today is the post-navigation
5413
- // settlement path in `persistPostNavigationRunStatus`, which goes
5414
- // directly through StorageDb — but the override is in place so
5415
- // future callers do not have to monkey-patch around saveState).
5416
- // 2. When no override is set AND a navigation-divergent run is in
5417
- // flight (currentRunView.runProjectId !== URL-derived id), log a
5418
- // warning and skip persistence. The settlement path owns that
5419
- // run's persistence; routing through saveState would write to the
5420
- // WRONG projectId record.
5610
+ // Settlement owns a run after navigation away from its project. Ordinary
5611
+ // view saves must not write that run into the current route's project.
5612
+ // An explicit override is reserved for callers that own a detached scope.
5421
5613
  const projectIdOverride = options && typeof options.projectIdOverride === 'string'
5422
5614
  ? options.projectIdOverride
5423
5615
  : '';
5424
5616
  const urlProjectId = getCurrentProjectId();
5425
5617
  const projectId = projectIdOverride || urlProjectId;
5426
5618
  if (!projectId) {
5427
- if (options?.reviewRunIds?.length) throw new Error('Review persistence lost its project.'); return;
5619
+ if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence lost its project.'); return;
5428
5620
  }
5429
5621
  // Read currentRunView through a defensive accessor so historical test
5430
5622
  // harnesses that inline `saveState` without re-declaring the module-
@@ -5436,13 +5628,14 @@
5436
5628
  'Skipped saveState: run is in flight on a different project than the current URL; settlement owns persistence.',
5437
5629
  '已跳过 saveState:本次运行所属项目与当前 URL 不一致,写回由 settlement 负责。'
5438
5630
  ));
5439
- if (options?.reviewRunIds?.length) throw new Error('Review persistence project changed.'); return;
5631
+ if (options?.requireDurable || options?.reviewRunIds?.length) throw new Error('Persistence project changed.'); return;
5440
5632
  }
5441
5633
  const compactState = prepareStateForStorage(state, { onAggressive: notifyAggressiveCompactionOnce });
5442
5634
  compactState.autoRecompile = state.autoRecompile;
5443
5635
  compactState.loadCodexLocalSkills = state.loadCodexLocalSkills !== false;
5444
5636
  compactState.loadCodexOverleafSkills = state.loadCodexOverleafSkills !== false;
5445
5637
  compactState.codexOverleafSkillEnabled = getCodexOverleafSkillEnabled();
5638
+ compactState.codexLocalSkillEnabled = getCodexLocalSkillEnabled();
5446
5639
  compactState.experimentalOtByProject = state.experimentalOtByProject;
5447
5640
  compactState.customInstructionsByProject = state.customInstructionsByProject;
5448
5641
  compactState.governanceRulesByProject = state.governanceRulesByProject;
@@ -5453,11 +5646,14 @@
5453
5646
  ? structuredClone(compactState)
5454
5647
  : JSON.parse(JSON.stringify(compactState));
5455
5648
 
5456
- const persistScopedState = persistenceContext => Modules.ScopedPersistenceCoordinator.persistPanelState({
5649
+ const retentionScope = { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '', projectId, generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null };
5650
+ let written;
5651
+ const persistScopedState = async persistenceContext => {
5652
+ written = await Modules.ScopedPersistenceCoordinator.persistPanelState({
5457
5653
  state: stateSnapshot,
5458
5654
  compactState: compactStateSnapshot,
5459
5655
  projectId,
5460
- deletedSessionIds: options?.deletedSessionIds, reviewRunIds: options?.reviewRunIds,
5656
+ deletedSessionIds: options?.deletedSessionIds, reviewRunIds: options?.reviewRunIds, recoveryRuns: options?.recoveryRuns,
5461
5657
  Migration,
5462
5658
  StorageDb,
5463
5659
  SessionPersistence: Modules.SessionPersistence,
@@ -5465,7 +5661,9 @@
5465
5661
  normalizeGovernanceRulesByProject,
5466
5662
  normalizeCustomInstructionsByProject,
5467
5663
  persistenceContext
5468
- });
5664
+ });
5665
+ await getComposerDraftPersistence().save({ accountScopeId: persistenceContext.scope.accountScopeId, projectId });
5666
+ };
5469
5667
 
5470
5668
  const persistenceCoordinator = getScopedPersistenceCoordinator();
5471
5669
  if (!persistenceCoordinator) {
@@ -5485,7 +5683,7 @@
5485
5683
  persistScopedState
5486
5684
  );
5487
5685
  if (!committed.ok && (
5488
- options?.reviewRunIds?.length || options?.queueMutation
5686
+ options?.requireDurable || options?.reviewRunIds?.length || options?.recoveryRuns?.length || options?.queueMutation
5489
5687
  || options?.queueMutations?.length
5490
5688
  || committed.reason !== 'stale_view'
5491
5689
  )) {
@@ -5493,8 +5691,23 @@
5493
5691
  code: committed.reason
5494
5692
  });
5495
5693
  }
5694
+ if (committed.ok) {
5695
+ const retentionUpdates = StorageDb.projectRetentionUpdates?.({ liveState: state, capturedState: stateSnapshot, written,
5696
+ capturedScope: retentionScope, currentScope: { accountScopeId: typeof cachedAccountScopeId === 'string' ? cachedAccountScopeId : '',
5697
+ projectId: getCurrentProjectId(), generation: typeof spaRouteGeneration === 'number' ? spaRouteGeneration : null } }) || [];
5698
+ for (const update of retentionUpdates) for (const run of [...(state.runs || []), ...(state.sessions || []).flatMap(session => session.runs || [])]) {
5699
+ if (run.id !== update.runId) continue;
5700
+ if (update.retention) run.recoveryRetention = update.retention; else delete run.recoveryRetention;
5701
+ }
5702
+ if (retentionUpdates.some(update => update.retention)) appendStorageNoticeOnce('undo-retention', tx(
5703
+ 'Some recovery evidence was not retained durably. After reloading, check Overleaf history or Review.',
5704
+ '部分恢复材料未持久保留;刷新后请核对 Overleaf 历史或审阅。'));
5705
+ }
5706
+ if (options?.requireDurable || options?.recoveryRuns?.length) return true;
5496
5707
  }
5497
5708
  } catch (error) {
5709
+ if (options?.requireDurable) throw error;
5710
+ if (error?.code === 'recovery_scope_changed') return;
5498
5711
  if (
5499
5712
  error?.code === 'account_scope_unavailable' &&
5500
5713
  !options?.reviewRunIds?.length && !options?.queueMutation &&
@@ -5522,7 +5735,7 @@
5522
5735
  emitStorageQuotaFailure(error, fallbackError);
5523
5736
  }
5524
5737
  if (typeof appendStorageNoticeOnce === 'function') {
5525
- appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${error.message}`, `保存会话状态失败:${error.message}`));
5738
+ appendStorageNoticeOnce('save-failed', tx(`Failed to save session state: ${localizeVisibleReason(error.message)}`, `保存会话状态失败:${localizeVisibleReason(error.message)}`));
5526
5739
  } else {
5527
5740
  throw error;
5528
5741
  }
@@ -5531,12 +5744,12 @@
5531
5744
  }
5532
5745
  }
5533
5746
 
5534
-
5535
5747
  function prepareCompactFallbackState(inputState) {
5536
5748
  const compact = prepareStateForStorage(inputState);
5537
5749
  return {
5538
5750
  ...compact,
5539
5751
  __codexOverleafCompactFallback: true,
5752
+ composerAttachments: [],
5540
5753
  task: '',
5541
5754
  session: null,
5542
5755
  sessions: [],
@@ -5629,7 +5842,7 @@
5629
5842
  // here (e.g. fallback chrome.storage.local.set rethrew).
5630
5843
  emitStorageQuotaFailure(error, null);
5631
5844
  }
5632
- if (persistenceOptions?.reviewRunIds?.length) throw error;
5845
+ if (persistenceOptions?.requireDurable || persistenceOptions?.reviewRunIds?.length) throw error;
5633
5846
  appendPlainLog(tx(`Failed to save session state: ${formatStateSaveError(error)}`, `保存会话状态失败:${formatStateSaveError(error)}`));
5634
5847
  })
5635
5848
  .finally(() => {
@@ -5705,7 +5918,7 @@
5705
5918
  if (isStorageQuotaError(error)) {
5706
5919
  return tx('Local session history is too large. Delete some old tasks and retry.', '本地会话记录太大,请删除一些旧任务后重试。');
5707
5920
  }
5708
- return error?.message || String(error);
5921
+ return localizeVisibleReason(error?.message || String(error));
5709
5922
  }
5710
5923
 
5711
5924
  async function persistPanelInputs(event) {
@@ -5817,18 +6030,59 @@
5817
6030
  return speedSelect?.value || state?.speedTier || 'standard';
5818
6031
  }
5819
6032
 
5820
-
5821
- let composerAttachmentsRestoredOnce = false;
6033
+ let composerDraftPersistence = null;
5822
6034
  let lastRunActivityBumpMs = 0;
5823
6035
 
6036
+ function getComposerDraftPersistence() {
6037
+ if (!composerDraftPersistence) composerDraftPersistence = Modules.ComposerAttachmentDraft.createController({
6038
+ getScope: () => ({ accountScopeId: cachedAccountScopeId, projectId: getCurrentProjectId() }),
6039
+ getAttachments: () => composerAttachmentController.serializePersistableAttachments(),
6040
+ setAttachments: value => { state.composerAttachments = value; },
6041
+ clear: () => composerAttachmentController.clear({ notify: false }),
6042
+ restore: value => composerAttachmentController.restorePersistedAttachments(value),
6043
+ read: scope => Modules.StorageDb.readComposerAttachmentDraft(scope),
6044
+ write: (scope, value) => Modules.StorageDb.writeComposerAttachmentDraft(scope, value),
6045
+ scheduleSave: () => saveStateSoon()
6046
+ });
6047
+ return composerDraftPersistence;
6048
+ }
6049
+
6050
+ async function loadComposerAttachmentsForProject(accountScopeId, projectId) {
6051
+ if (!accountScopeId || !projectId) return [];
6052
+ try {
6053
+ return await getComposerDraftPersistence().load({ accountScopeId, projectId });
6054
+ } catch (error) {
6055
+ if (error?.code === 'stale_hydration' || error?.code === 'account_scope_unavailable') throw error;
6056
+ appendPlainLog(tx('Saved attachments could not be restored. Reattach the files before sending.',
6057
+ '已保存的附件无法恢复,请在发送前重新添加文件。'));
6058
+ return [];
6059
+ }
6060
+ }
6061
+
5824
6062
  function persistComposerAttachmentsToState() {
5825
6063
  if (typeof composerAttachmentController?.serializePersistableAttachments === 'function') {
5826
6064
  state.composerAttachments = composerAttachmentController.serializePersistableAttachments();
5827
6065
  }
5828
6066
  }
5829
6067
 
5830
- function clearTaskComposer({ keepAttachments = false } = {}) {
6068
+ function clearTaskComposer({ keepAttachments = false, submission = null } = {}) {
5831
6069
  const taskInput = panel?.querySelector('[data-task]');
6070
+ if (submission) {
6071
+ if (submission.projectId !== getCurrentProjectId()
6072
+ || submission.accountScopeId !== cachedAccountScopeId) return false;
6073
+ if (findSessionById(submission.sessionId)?.task !== submission.task) return false;
6074
+ if (state.activeSessionId !== submission.sessionId) {
6075
+ updateSessionById(submission.sessionId, { task: '' });
6076
+ saveStateSoon();
6077
+ return false;
6078
+ }
6079
+ if (composerAttachmentController.getAttachmentByteUsage?.().pending > 0
6080
+ || selectionContextView.getRevision?.() !== submission.selectionRevision) return false;
6081
+ if (!runController.isSubmittedComposerUnchanged(submission, {
6082
+ task: taskInput?.value, attachments: getComposerAttachmentsForRun(),
6083
+ skillInvocation: getComposerSkillInvocationForRun(), selectionContext: selectionContextView.capture()
6084
+ })) return false;
6085
+ }
5832
6086
  if (taskInput) {
5833
6087
  taskInput.value = '';
5834
6088
  }
@@ -5841,6 +6095,30 @@
5841
6095
  selectionContextView.clear();
5842
6096
  state = updateActiveSession(state, { task: '' });
5843
6097
  saveStateSoon();
6098
+ return true;
6099
+ }
6100
+
6101
+ function restoreRejectedLocalSkillSubmission(submission, checkpoint) {
6102
+ const input = panel?.querySelector('[data-task]');
6103
+ if (!checkpoint || !input || submission.accountScopeId !== cachedAccountScopeId
6104
+ || submission.projectId !== getCurrentProjectId() || submission.sessionId !== state.activeSessionId
6105
+ || !findSessionById(submission.sessionId) || input.value || state.task
6106
+ || composerDraftRevision !== checkpoint.draftRevision
6107
+ || getComposerAttachmentsForRun().length || composerAttachmentController.getAttachmentByteUsage?.().pending
6108
+ || composerAttachmentController.getRevision?.() !== checkpoint.attachmentRevision
6109
+ || selectionContextView.getRevision?.() !== checkpoint.selectionRevision
6110
+ || selectionContextView.capture() || getComposerSkillInvocationForRun()) return false;
6111
+ if (!composerAttachmentController.restoreSubmittedAttachments?.(submission.attachments, checkpoint.attachmentRevision)) return false;
6112
+ composerDraftRevision += 1;
6113
+ composerSkillInvocation = submission.skillInvocation;
6114
+ input.value = submission.task;
6115
+ state = updateActiveSession(state, { task: submission.task, selectionContext: submission.selectionContext });
6116
+ renderComposerSkillInvocation();
6117
+ selectionContextView.render();
6118
+ autosizeTaskTextarea();
6119
+ syncComposerSendAvailability();
6120
+ saveStateSoon();
6121
+ return true;
5844
6122
  }
5845
6123
 
5846
6124
  function applyStateToPanel(options = {}) {
@@ -5882,23 +6160,14 @@
5882
6160
  applySessionLabel();
5883
6161
  renderSessionList();
5884
6162
  renderRunHistory(options);
6163
+ renderPendingInputs();
5885
6164
  getTrackedChangeCaptureController().schedule();
5886
6165
  renderContextSelection();
5887
6166
  renderContextSummary();
5888
- // v1.8.0 C4: refresh recovery — small attachments persisted in state are
5889
- // restored into the (empty) tray ONCE per page load, before the render
5890
- // below. Re-running the gate on later renders would resurrect files the
5891
- // user explicitly removed (fleet P2).
5892
- if (!composerAttachmentsRestoredOnce
5893
- && Array.isArray(state.composerAttachments) && state.composerAttachments.length
5894
- && !composerAttachmentController.getAttachmentsForRun?.().length
5895
- && typeof composerAttachmentController.restorePersistedAttachments === 'function') {
5896
- composerAttachmentController.restorePersistedAttachments(state.composerAttachments);
5897
- }
5898
- composerAttachmentsRestoredOnce = true;
6167
+ getComposerDraftPersistence().hydrate(state.composerAttachments);
5899
6168
  renderComposerAttachments();
5900
6169
  renderComposerSkillInvocation();
5901
- applyLocaleToPanel();
6170
+ applyLocaleToPanel({ reprojectDynamic: false });
5902
6171
  if (!panel.querySelector('[data-context-tray]')?.hidden) {
5903
6172
  renderContextFiles();
5904
6173
  }
@@ -5959,6 +6228,9 @@
5959
6228
  }
5960
6229
 
5961
6230
  function startRunView({
6231
+ sessionId = state.activeSessionId,
6232
+ sessionContext = runController.captureSessionContext(state),
6233
+ codexThreadId = '',
5962
6234
  task,
5963
6235
  mode,
5964
6236
  model,
@@ -5999,41 +6271,39 @@
5999
6271
  executionSnapshot: executionSnapshot
6000
6272
  ? RunExecutionSnapshot.cloneSnapshot(executionSnapshot)
6001
6273
  : undefined,
6002
- // Welcome-panel + write-guard:
6003
- // Immutable per-run capture of the project this run was submitted
6004
- // against. Every writeback / accept / undo dispatch attaches this id
6005
- // to its page-bridge params so the page-side guard can verify the run
6006
- // is still acting on the same project before mutating the editor. No
6007
- // language-level enforcement — the field is treated as immutable by
6008
- // convention and never reassigned anywhere downstream.
6274
+ // Writeback/Accept/Undo carry this immutable project identity to Page.
6009
6275
  runProjectId: getCurrentProjectId() || '',
6010
6276
  runAccountScopeId: cachedAccountScopeId || ''
6011
6277
  };
6012
- const active = getActiveSession(state);
6278
+ const active = findSessionById(sessionId);
6013
6279
  const titlePatch = active?.titleSource !== 'manual'
6014
6280
  ? {
6015
6281
  title: deriveSessionTitle([], task),
6016
6282
  titleSource: 'auto'
6017
6283
  }
6018
6284
  : {};
6019
- state = updateActiveSession(state, {
6020
- runs: runController.replaceRunForRetry(state.runs, replaceRunId, record),
6285
+ updateSessionById(sessionId, {
6286
+ runs: runController.replaceRunForRetry(active?.runs, replaceRunId, record),
6021
6287
  ...titlePatch
6022
6288
  });
6023
6289
  saveStateSoon(replaceRunId ? 0 : 120);
6024
6290
 
6025
6291
  const log = panel.querySelector('[data-log]');
6026
- removeEmptyRunsMessage(log);
6027
- if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
6028
6292
  const root = renderRunCard(record);
6029
- log.append(root);
6030
- scrollLogToBottom({ force: true });
6293
+ if (state.activeSessionId === sessionId) {
6294
+ removeEmptyRunsMessage(log);
6295
+ if (replaceRunId) log.querySelector(`[data-run-id="${cssEscape(replaceRunId)}"]`)?.remove();
6296
+ log.append(root);
6297
+ scrollLogToBottom({ force: true });
6298
+ }
6031
6299
  startRunElapsedTick();
6032
6300
  renderSessionList();
6033
6301
  applySessionLabel();
6034
6302
 
6035
6303
  return {
6036
- sessionId: state.activeSessionId,
6304
+ sessionId,
6305
+ sessionContext,
6306
+ codexThreadId,
6037
6307
  recordId: record.id,
6038
6308
  // Mirror the immutable runProjectId on the view so writeback /
6039
6309
  // accept / undo dispatches don't need to re-look up the record.
@@ -6592,311 +6862,34 @@
6592
6862
  );
6593
6863
  }
6594
6864
 
6595
- async function undoRun(runId) {
6596
- const run = findRunRecord(runId);
6597
- if (!getRunUndoCount(run) || run.undoStatus === 'applied') {
6598
- return;
6599
- }
6600
- // Wait out any background mirror refresh before undoing (v1.7.5): a
6601
- // mirror.sync finishing after the undo would push the pre-undo snapshot
6602
- // as the local baseline and resurrect the undone content next run.
6603
- const pendingMirror = getPendingMirrorRefresh?.();
6604
- if (pendingMirror) {
6605
- await pendingMirror.catch(() => {});
6606
- }
6607
-
6608
- if ((Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length) || hasTrackedEditorUndo(run)
6609
- || (run.trackedChangeStatus === 'needs_review' && run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1))) {
6610
- await undoRunTrackedChanges(runId, run);
6611
- return;
6612
- }
6613
-
6614
- const undoRestore = buildNoTraceUndoRestore(run);
6615
- const undoOperations = undoRestore.operations;
6616
- const unsafeFullFileUndo = findUnsafeFullFileUndoOperation(undoOperations, {
6617
- allowSnapshotRestore: undoRestore.snapshotRestore
6618
- });
6619
- if (unsafeFullFileUndo) {
6620
- appendRunRecordEvent(runId, {
6621
- title: tr('undoUnsafeFullFileTitle'),
6622
- status: 'failed',
6623
- detail: {
6624
- [tr('detailFile')]: unsafeFullFileUndo.path,
6625
- [tr('detailReason')]: tr('undoUnsafeFullFileReason')
6626
- }
6627
- });
6628
- return;
6629
- }
6630
-
6631
- // v1.8.0 C3: multi-file undos confirm through a per-file selection —
6632
- // undoing a subset leaves undoStatus 'partial' and the button usable;
6633
- // re-undoing an already-restored file is skipped by the base check.
6634
- const undoPaths = Array.from(new Set(undoOperations.map(operation => operation?.path).filter(Boolean)));
6635
- let selectedOperations = undoOperations;
6636
- if (undoPaths.length > 1 && typeof showUndoFileSelection === 'function') {
6637
- const selectedPaths = await showUndoFileSelection({
6638
- title: tr('undoFileSelectTitle'),
6639
- task: truncateRunTitle(run.task),
6640
- confirmLabel: tr('undoFileSelectConfirm'),
6641
- cancelLabel: tr('confirmDefaultCancel'),
6642
- selectAllLabel: tr('undoFileSelectAll'),
6643
- paths: undoPaths
6644
- });
6645
- if (!selectedPaths) {
6646
- return;
6647
- }
6648
- const selectedSet = new Set(selectedPaths);
6649
- selectedOperations = undoOperations.filter(operation => selectedSet.has(operation?.path));
6650
- } else {
6651
- const approved = await showPluginConfirm({
6652
- title: tr('undoNoTraceTitle'),
6653
- message: [
6654
- truncateRunTitle(run.task),
6655
- '',
6656
- tr('undoNoTraceMessage', { files: formatOperationFiles(undoOperations) })
6657
- ].join('\n'),
6658
- confirmLabel: tr('undoConfirm'),
6659
- cancelLabel: tr('confirmDefaultCancel'),
6660
- destructive: true
6661
- });
6662
- if (!approved) {
6663
- return;
6664
- }
6665
- }
6666
-
6667
- const deadlineAt = Date.now() + getPageBridgeTimeoutMs('rejectTrackedChanges');
6668
- setRunUndoStatus(runId, 'running');
6669
- appendRunRecordEvent(runId, {
6670
- title: tr('undoNoTraceStarted'),
6671
- status: 'running',
6672
- detail: { [tr('detailWillUndo')]: formatOperationFiles(selectedOperations) }
6865
+ let runReviewController = null;
6866
+ function getRunReviewController() {
6867
+ if (!runReviewController) runReviewController = Modules.RunReviewController.create({
6868
+ Modules, WritebackSettlement, trackedChangeInFlight, findRunRecord,
6869
+ getRunUndoCount, isTrackedChangeLifecycleRun, getPendingMirrorRefresh, hasTrackedEditorUndo, buildNoTraceUndoRestore,
6870
+ findUnsafeFullFileUndoOperation, showUndoFileSelection, showPluginConfirm, tr,
6871
+ truncateRunTitle, formatOperationFiles, formatTrackedChangeFiles, formatTrackedUndoFiles,
6872
+ getPageBridgeTimeoutMs, callPageBridge, setRunUndoStatus, appendRunRecordEvent, getRunProjectIdForWriteback,
6873
+ isUndoResultEffectivelyApplied, writebackOrchestrator, appendUndoReviewingPolicyEvent, formatOperationType,
6874
+ formatApplyResultReason, buildTrackedUndoPostFiles, getTrackedChangeCaptureController, buildContentFailure,
6875
+ applyTrackedChangeSettlement, refreshRunCardControls, formatBridgeResultReason, advanceRunUndoProgress,
6876
+ saveStateSoon, flushQueuedSaveState,
6877
+ getState: () => state, getCurrentProjectId, getAccountScopeId: () => cachedAccountScopeId,
6878
+ getRouteGeneration: () => spaRouteGeneration, tx, notify: message => showPluginToast(message, { status: 'warning', sticky: true })
6673
6879
  });
6880
+ return runReviewController;
6881
+ }
6674
6882
 
6675
- const result = await callPageBridge('applyOperations', {
6676
- operations: selectedOperations, deadlineAt,
6677
- baseFiles: run.undoBaseFiles || [],
6678
- reviewingPolicy: 'no-trace-undo',
6679
- // Undo remains bound to its original project, including after navigation.
6680
- runProjectId: getRunProjectIdForWriteback(run)
6681
- });
6682
- const undoApplied = isUndoResultEffectivelyApplied(run, result);
6683
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6684
- appendUndoReviewingPolicyEvent(runId, result.reviewingPolicy);
6685
- appendRunRecordEvent(runId, {
6686
- title: tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6687
- status: undoApplied ? 'completed' : result.skipped?.length ? 'failed' : 'completed',
6688
- detail: {
6689
- [tr('detailUndone')]: (result.applied || []).map(item => ({
6690
- [tr('detailAction')]: formatOperationType(item.operation?.type),
6691
- [tr('detailFile')]: item.operation?.path
6692
- })),
6693
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6694
- [tr('detailAction')]: formatOperationType(item.operation?.type),
6695
- [tr('detailFile')]: item.operation?.path,
6696
- [tr('detailReason')]: formatApplyResultReason(item)
6697
- }))
6698
- }
6699
- });
6700
- // A subset undo never closes the ledger: unselected files can still
6701
- // be undone later, so the run stays 'partial' and the button usable.
6702
- const fullSelection = selectedOperations.length === undoOperations.length;
6703
- applyLegacyUndoSettlement(
6704
- runId,
6705
- undoApplied && fullSelection ? 'applied' : 'partial',
6706
- result
6707
- );
6883
+ async function undoRun(runId) {
6884
+ return getRunReviewController().undoRun(runId);
6708
6885
  }
6709
6886
 
6710
6887
  async function undoRunTrackedChanges(runId, run) {
6711
- const createdUndoOperations = (run.undoOperations || []).filter(operation => operation?.undoCreatedFile?.v === 1);
6712
- const trackedUndo = Array.isArray(run.undoTrackedChanges) && run.undoTrackedChanges.length > 0;
6713
- const hasTextUndo = trackedUndo || buildTrackedUndoPostFiles(run).length > 0;
6714
- const approved = await showPluginConfirm({
6715
- title: !hasTextUndo ? tr('undoNoTraceTitle') : trackedUndo ? tr('undoTrackedTitle') : tr('undoNativeTitle'),
6716
- message: [
6717
- truncateRunTitle(run.task),
6718
- '',
6719
- ...(hasTextUndo ? [trackedUndo
6720
- ? tr('undoTrackedMessage', { files: formatTrackedChangeFiles(run.undoTrackedChanges) })
6721
- : tr('undoNativeMessage', { files: formatTrackedUndoFiles(run) })] : []),
6722
- ...(createdUndoOperations.length ? [tr('undoNoTraceMessage', { files: formatOperationFiles(createdUndoOperations) })] : [])
6723
- ].join('\n'),
6724
- confirmLabel: trackedUndo ? tr('undoTrackedConfirm') : tr('undoConfirm'),
6725
- cancelLabel: tr('confirmDefaultCancel'),
6726
- destructive: true
6727
- });
6728
- if (!approved) {
6729
- return;
6730
- }
6731
-
6732
- const deadlineAt = Date.now() + getPageBridgeTimeoutMs('rejectTrackedChanges');
6733
- // Keep the review lifecycle until every pending part has settled.
6734
- // Its local lock stays active while verified recovery progress is persisted.
6735
- const lifecycleReject = (trackedUndo || createdUndoOperations.length > 0) && isTrackedChangeLifecycleRun(run);
6736
- if (lifecycleReject) {
6737
- trackedChangeInFlight.set(runId, 'reject');
6738
- refreshRunCardControls(runId);
6739
- } else {
6740
- setRunUndoStatus(runId, 'running');
6741
- }
6742
- appendRunRecordEvent(runId, {
6743
- title: !hasTextUndo ? tr('undoNoTraceStarted') : trackedUndo ? tr('undoTrackedStarted') : tr('undoNativeStarted'),
6744
- status: 'running',
6745
- detail: { [tr('detailWillUndo')]: !hasTextUndo ? formatOperationFiles(createdUndoOperations)
6746
- : trackedUndo ? formatTrackedChangeFiles(run.undoTrackedChanges) : formatTrackedUndoFiles(run) }
6747
- });
6748
-
6749
- try {
6750
- const trackedUndoPostFilesAtDispatch = buildTrackedUndoPostFiles(run);
6751
- let result = hasTextUndo ? await callPageBridge('rejectTrackedChanges', {
6752
- trackedChanges: run.undoTrackedChanges || [], deadlineAt,
6753
- expectedFiles: run.undoExpectedFiles || [],
6754
- postFiles: trackedUndoPostFilesAtDispatch,
6755
- untrackedUndo: run.executionSnapshot
6756
- ? run.executionSnapshot.requireReviewing === false && ['submitted', 'legacy-captured'].includes(run.executionSnapshot.source)
6757
- : getTrackedChangeCaptureController().hasLegacyUntrackedCheckpoint(run),
6758
- // The page-side project guard still refuses writes after navigation.
6759
- runProjectId: getRunProjectIdForWriteback(run)
6760
- }) : { ok: true, applied: [], skipped: [] };
6761
- if (createdUndoOperations.length && lifecycleReject && hasTextUndo) {
6762
- WritebackSettlement.attachUndoNotVerifiedFailure(run, result, { buildFailure: buildContentFailure, postFiles: trackedUndoPostFilesAtDispatch });
6763
- await applyTrackedChangeSettlement(runId, 'reject', { ...result, ok: false });
6764
- }
6765
- if (createdUndoOperations.length) {
6766
- let createdResult;
6767
- try {
6768
- if (result.ok === false || result.skipped?.length) {
6769
- throw new Error('Text changes remain unresolved; created files were kept for the next undo attempt.');
6770
- }
6771
- createdResult = await callPageBridge('applyOperations', {
6772
- operations: createdUndoOperations, deadlineAt,
6773
- baseFiles: run.undoBaseFiles || [],
6774
- reviewingPolicy: 'no-trace-undo',
6775
- runProjectId: getRunProjectIdForWriteback(run)
6776
- });
6777
- } catch (error) {
6778
- createdResult = { ok: false, applied: [], skipped: createdUndoOperations.map(operation => ({
6779
- operation, result: { ok: false, code: 'undo_operation_failed', reason: error.message }
6780
- })) };
6781
- }
6782
- const applied = [...(result.applied || []), ...(createdResult.applied || [])];
6783
- const skipped = [...(result.skipped || []), ...(createdResult.skipped || [])];
6784
- for (const operation of createdUndoOperations) {
6785
- if (!(createdResult.applied || []).some(entry => entry.operation?.path === operation.path
6786
- && entry.result?.ok === true && entry.result?.verified === true)
6787
- && !(createdResult.skipped || []).some(entry => entry.operation?.path === operation.path)) {
6788
- skipped.push({ operation, result: { ok: false, code: 'undo_operation_failed',
6789
- reason: 'The created file has no verified deletion receipt.' } });
6790
- }
6791
- }
6792
- result = { ...result, ok: result.ok !== false && createdResult.ok !== false && !skipped.length, applied, skipped };
6793
- }
6794
- writebackOrchestrator.invalidateMirrorAfterUndo(runId, getRunProjectIdForWriteback(run), result);
6795
- appendRunRecordEvent(runId, {
6796
- title: createdUndoOperations.length ? tr('undoResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }) : trackedUndo
6797
- ? tr('undoTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 })
6798
- : tr('undoNativeResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6799
- status: result.skipped?.length ? 'failed' : 'completed',
6800
- detail: {
6801
- [trackedUndo && !createdUndoOperations.length ? tr('detailRejected') : tr('detailUndone')]: (result.applied || []).map(item => ({
6802
- [tr('detailFile')]: item.trackedChange?.path || item.operation?.path || tr('unknownFile'),
6803
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || item.operation?.type
6804
- })),
6805
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6806
- [tr('detailFile')]: item.trackedChange?.path || item.operation?.path || tr('unknownFile'),
6807
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || item.operation?.type || '',
6808
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path || item.operation?.path)
6809
- }))
6810
- }
6811
- });
6812
- if (lifecycleReject) {
6813
- WritebackSettlement.attachUndoNotVerifiedFailure(run, result, {
6814
- buildFailure: buildContentFailure,
6815
- postFiles: trackedUndoPostFilesAtDispatch
6816
- });
6817
- await applyTrackedChangeSettlement(runId, 'reject', result);
6818
- return;
6819
- }
6820
- applyLegacyUndoSettlement(runId, result.skipped?.length ? 'partial' : 'applied', result);
6821
- } finally {
6822
- if (lifecycleReject) {
6823
- trackedChangeInFlight.delete(runId);
6824
- refreshRunCardControls(runId);
6825
- }
6826
- }
6888
+ return getRunReviewController().undoRunTrackedChanges(runId, run);
6827
6889
  }
6828
6890
 
6829
6891
  async function acceptRun(runId) {
6830
- const run = findRunRecord(runId);
6831
- if (!run || !isTrackedChangeLifecycleRun(run)) {
6832
- return;
6833
- }
6834
- const status = run.trackedChangeStatus || '';
6835
- // pending and needs_review are both actionable per §7: needs_review means
6836
- // the prior attempt could not prove a clean post-action state, and the
6837
- // user is supposed to be able to retry after inspecting Overleaf.
6838
- if (status !== 'pending' && status !== 'needs_review') {
6839
- return;
6840
- }
6841
- if (!Array.isArray(run.undoTrackedChanges) || !run.undoTrackedChanges.length) {
6842
- return;
6843
- }
6844
- if (trackedChangeInFlight.get(runId)) {
6845
- return;
6846
- }
6847
-
6848
- trackedChangeInFlight.set(runId, 'accept');
6849
- refreshRunCardControls(runId);
6850
- appendRunRecordEvent(runId, {
6851
- title: tr('runAcceptTrackedStarted'),
6852
- status: 'running',
6853
- detail: { [tr('detailAccepted')]: formatTrackedChangeFiles(run.undoTrackedChanges) }
6854
- });
6855
-
6856
- try {
6857
- const result = await callPageBridge('acceptTrackedChanges', {
6858
- trackedChanges: run.undoTrackedChanges || [],
6859
- expectedFiles: run.undoExpectedFiles || [],
6860
- postFiles: buildTrackedUndoPostFiles(run),
6861
- appliedOperations: Array.isArray(run.appliedOperations) ? run.appliedOperations : [],
6862
- runProjectId: getRunProjectIdForWriteback(run)
6863
- });
6864
- appendAcceptDiagnosticEvents(runId, Array.isArray(result.diagnostics) ? result.diagnostics : []);
6865
- appendRunRecordEvent(runId, {
6866
- title: tr('runAcceptTrackedResult', { applied: result.applied?.length || 0, skipped: result.skipped?.length || 0 }),
6867
- status: result.skipped?.length ? 'failed' : 'completed',
6868
- detail: {
6869
- [tr('detailAccepted')]: (result.applied || []).map(item => ({
6870
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6871
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key
6872
- })),
6873
- [tr('detailSkipped')]: (result.skipped || []).map(item => ({
6874
- [tr('detailFile')]: item.trackedChange?.path || tr('unknownFile'),
6875
- [tr('detailRecord')]: item.trackedChange?.label || item.trackedChange?.id || item.trackedChange?.key || '',
6876
- [tr('detailReason')]: formatBridgeResultReason(item.result, item.trackedChange?.path)
6877
- }))
6878
- }
6879
- });
6880
- if (result.ok === false) {
6881
- appendRunRecordEvent(runId, {
6882
- title: tr('runAcceptTrackedFailed'),
6883
- status: 'failed',
6884
- detail: {
6885
- [tr('detailReason')]: tr('runAcceptTrackedFailedReason')
6886
- }
6887
- });
6888
- if (!WritebackSettlement.isSuccessfulTrackedChangeSettlement(result)) {
6889
- refreshRunCardControls(runId);
6890
- }
6891
- }
6892
- WritebackSettlement.attachAcceptNotVerifiedFailure(run, result, {
6893
- buildFailure: buildContentFailure
6894
- });
6895
- await applyTrackedChangeSettlement(runId, 'accept', result);
6896
- } finally {
6897
- trackedChangeInFlight.delete(runId);
6898
- refreshRunCardControls(runId);
6899
- }
6892
+ return getRunReviewController().acceptRun(runId);
6900
6893
  }
6901
6894
 
6902
6895
  function getRunProjectIdForWriteback(run) {
@@ -6905,32 +6898,6 @@
6905
6898
  : '';
6906
6899
  }
6907
6900
 
6908
- // Translates the page bridge's Accept All `diagnostics` array into one
6909
- // run-card event row per step. Each entry is `{ step, info }`; the step
6910
- // names map 1:1 to the runAcceptTrackedStep* i18n keys. We render `info`
6911
- // as the event detail so the user can copy-paste the full trace without
6912
- // any extra page-state query.
6913
- function appendAcceptDiagnosticEvents(runId, diagnostics) {
6914
- for (const entry of diagnostics || []) {
6915
- const step = entry && entry.step;
6916
- const info = (entry && entry.info) || {};
6917
- const titleKey = WritebackSettlement.ACCEPT_DIAGNOSTIC_TITLE_KEYS[step];
6918
- if (!titleKey) {
6919
- continue;
6920
- }
6921
- const title = titleKey === 'runAcceptTrackedStepReplayStart' || titleKey === 'runAcceptTrackedStepReplayDone'
6922
- ? tr(titleKey, { path: info.path || tr('unknownFile') })
6923
- : tr(titleKey);
6924
- const status = WritebackSettlement.acceptDiagnosticStatus(step, info);
6925
- appendRunRecordEvent(runId, {
6926
- title,
6927
- kind: 'activity',
6928
- status,
6929
- detail: info
6930
- });
6931
- }
6932
- }
6933
-
6934
6901
  function buildContentFailure(code, operation, overrides) {
6935
6902
  return WritebackSettlement.buildContentFailure(
6936
6903
  code,
@@ -6940,27 +6907,29 @@
6940
6907
  );
6941
6908
  }
6942
6909
 
6943
- async function applyTrackedChangeSettlement(runId, kind, result) {
6910
+ async function applyTrackedChangeSettlement(runId, kind, result, options = {}) {
6944
6911
  const record = findRunRecord(runId);
6945
- const createdUndo = record?.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1);
6946
- const run = kind === 'reject' && createdUndo ? advanceRunUndoProgress(record, result) : record;
6912
+ const run = kind === 'reject' ? advanceRunUndoProgress(record, result) : record;
6947
6913
  const settlement = WritebackSettlement.settleTrackedChangeLifecycle({
6948
6914
  kind,
6949
6915
  run,
6950
6916
  result,
6951
6917
  failureReasons: FailureReasons
6952
6918
  });
6953
- if (settlement.decision === 'blocked' && !(kind === 'reject' && createdUndo)) {
6919
+ if (settlement.decision === 'blocked'
6920
+ && !(kind === 'reject' && (run !== record || record?.undoOperations?.some(Modules.UndoOperations.isFileUndoOperation)))) {
6954
6921
  return;
6955
6922
  }
6956
6923
  await Modules.ScopedPersistenceCoordinator.applyReviewTransition({
6957
6924
  getState: () => state, runId, transition: WritebackSettlement.applySettlementTransition(run, settlement),
6958
- persist: flushQueuedSaveState
6925
+ persist: flushQueuedSaveState, assertCurrent: options.assertCurrent
6959
6926
  });
6960
6927
  }
6961
6928
 
6962
6929
 
6963
6930
  function advanceRunUndoProgress(run, result) {
6931
+ getDirectoryCleanupRecovery().capture({ recordId: run.id, sessionId: state.activeSessionId,
6932
+ runProjectId: run.runProjectId, runAccountScopeId: run.runAccountScopeId }, result);
6964
6933
  const postFiles = buildTrackedUndoPostFiles(run);
6965
6934
  return Modules.UndoOperations.advanceUndoCheckpoint(run, result, file =>
6966
6935
  WritebackSettlement.isUndoVerifiedContentMatching({ undoExpectedFiles: [file] }, result, { postFiles })
@@ -6972,9 +6941,9 @@
6972
6941
  }
6973
6942
  const trackedEditorUndoCount = hasTrackedEditorUndo(run) ? 1 : 0;
6974
6943
  if (trackedEditorUndoCount) {
6975
- return trackedEditorUndoCount + (run.undoOperations || []).filter(operation => operation?.undoCreatedFile?.v === 1).length;
6944
+ return trackedEditorUndoCount + Modules.UndoOperations.getFileUndoOperations(run).length;
6976
6945
  }
6977
- return (run.undoOperations?.length || 0)
6946
+ return Modules.UndoOperations.prepareRunUndo(run).undoOperations.length
6978
6947
  + (run.undoTrackedChanges?.length || 0);
6979
6948
  }
6980
6949
 
@@ -6986,17 +6955,39 @@
6986
6955
  sessionId: target.sessionId, runId: target.recordId, createdAt: new Date().toISOString() };
6987
6956
  }
6988
6957
 
6958
+ let directoryCleanupRecovery = null;
6959
+ function getDirectoryCleanupRecovery() {
6960
+ return directoryCleanupRecovery || (directoryCleanupRecovery = Modules.DirectoryCleanupRecovery.create({
6961
+ getProjectId: getCurrentProjectId, getAccountScopeId: () => cachedAccountScopeId,
6962
+ getRouteGeneration: () => spaRouteGeneration, findRunRecord, refresh: refreshRunCardControls,
6963
+ onOmitted: (runId, code) => appendRunRecordEvent(runId, { kind: 'technical', status: 'warning',
6964
+ title: tx('Some folder observations exceeded the storage limit; existing observations were kept.',
6965
+ '部分文件夹观测记录超过存储上限,已有记录已保留。'), detail: { code } })
6966
+ }));
6967
+ }
6989
6968
  async function stageWritebackRecovery(operations, target, requireReviewing) {
6990
- const record = findRunRecord(target.recordId, target.sessionId);
6991
- if (!record) throw new Error('The writeback run is no longer available.');
6969
+ const owner = recoveryOwner(target), generation = spaRouteGeneration, view = currentRunView;
6970
+ const assertCurrent = () => {
6971
+ const record = findRunRecord(target.recordId, target.sessionId);
6972
+ if (owner.projectId !== getCurrentProjectId() || owner.accountScopeId !== cachedAccountScopeId
6973
+ || generation !== spaRouteGeneration || view !== currentRunView || !view
6974
+ || view.recordId !== owner.runId || view.sessionId !== owner.sessionId
6975
+ || (target.runAccountScopeId && target.runAccountScopeId !== owner.accountScopeId)
6976
+ || !record || record.forkSnapshot || record.runProjectId !== owner.projectId) {
6977
+ throw new Error('The project, account or writeback run changed before dispatch.');
6978
+ }
6979
+ return record;
6980
+ };
6981
+ const record = assertCurrent();
6992
6982
  const creates = operations.filter(op => op.type === 'create' && typeof op.content === 'string');
6993
- record.retryRequireReviewing = requireReviewing === true; // In-memory; seeds an edit-only retry intent.
6983
+ record.retryRequireReviewing = requireReviewing === true;
6994
6984
  if (!creates.length) return;
6995
- const candidate = Modules.WritebackIntent.normalize({ ...recoveryOwner(target), id: crypto.randomUUID(),
6985
+ const candidate = Modules.WritebackIntent.normalize({ ...owner, id: crypto.randomUUID(),
6996
6986
  operations: creates, baseFiles: [], requireReviewing });
6997
6987
  const safe = Modules.SessionState.pickWritebackRecovery({ ...record, retryWriteback: candidate }).retryWriteback;
6998
6988
  if (safe) record.retryWriteback = safe; else delete record.retryWriteback;
6999
6989
  await flushQueuedSaveState();
6990
+ assertCurrent();
7000
6991
  }
7001
6992
 
7002
6993
  async function settleWritebackRecovery(applied, project, verification, target) {
@@ -7040,6 +7031,8 @@
7040
7031
  || owner.accountScopeId !== cachedAccountScopeId || owner.sessionId !== session.id
7041
7032
  || owner.runId !== record.id || record.undoStatus === 'applied' || record.trackedChangeStatus === 'rejected') return;
7042
7033
  const originalStatus = record.status;
7034
+ const retryAutoRecompile = typeof record.executionSnapshot?.autoRecompile === 'boolean'
7035
+ ? record.executionSnapshot.autoRecompile : undefined;
7043
7036
  // State is renormalized during the awaits below (probes, session sync), which
7044
7037
  // replaces run objects; always write through the current copy.
7045
7038
  const live = () => findRunRecord(runId, session.id) || record;
@@ -7075,6 +7068,7 @@
7075
7068
  const result = await applySyncChangesToOverleaf(intent.operations.map(op => op.type === 'edit'
7076
7069
  ? { type: 'write', path: op.path, patches: op.patches, content: op.replaceAll } : op.type === 'delete' ? { type: 'delete', path: op.path } : { ...op, type: 'create' }),
7077
7070
  { id: owner.projectId, files: intent.baseFiles }, { mode: 'auto', requireReviewing: intent.requireReviewing,
7071
+ autoRecompile: retryAutoRecompile,
7078
7072
  retryCreates: true, assistantMessage: tx('Retried the remaining file transfers for this task.', '已重试本轮未完成的文件同步。') });
7079
7073
  assertOwner();
7080
7074
  if (result.settlement?.facts) live().settlement = WritebackSettlement.mergeRetrySettlement(live().settlement, result.settlement.facts); // Keep files outside the retry.
@@ -7195,7 +7189,9 @@
7195
7189
  });
7196
7190
  }
7197
7191
 
7198
- function recordUndoFromApply(project, applyResult) {
7192
+ async function recordUndoFromApply(project, applyResult) {
7193
+ const directoryRecovery = Modules.DirectoryCleanupRecovery ? getDirectoryCleanupRecovery() : null;
7194
+ directoryRecovery?.capture(currentRunView, applyResult);
7199
7195
  const appliedEntries = getAppliedEntries(applyResult).filter(entry => entry.result?.idempotent !== true);
7200
7196
  if (!currentRunView?.recordId || !appliedEntries.length) {
7201
7197
  return;
@@ -7231,6 +7227,7 @@
7231
7227
  combinedTrackedChanges,
7232
7228
  record.undoExpectedFiles
7233
7229
  );
7230
+ record.undoOperations = Modules.UndoOperations.getFileUndoOperations(record);
7234
7231
  record.undoStatus = '';
7235
7232
  record.partialWriteback = skippedEntries.length > 0;
7236
7233
  // Recording tracked-change refs enters the run into the tracked-change
@@ -7363,6 +7360,7 @@
7363
7360
  activity: Modules.RunActivityModel.capture(event, sanitizeAssistantVisibleText),
7364
7361
  guidanceId: sanitizeAssistantVisibleText(event.guidanceId)
7365
7362
  };
7363
+ if (event.failure) normalized.failure = sanitizeAssistantVisibleValue(event.failure);
7366
7364
  record.events = [...(record.events || []), normalized].slice(-MAX_RUN_EVENTS);
7367
7365
  saveStateSoon();
7368
7366
 
@@ -7387,21 +7385,8 @@
7387
7385
  refreshRunCardControls(runId);
7388
7386
  }
7389
7387
 
7390
- function applyLegacyUndoSettlement(runId, undoStatus, result) {
7391
- const run = findRunRecord(runId);
7392
- if (!run) return;
7393
- const checkpoint = run.undoOperations?.some(operation => operation?.undoCreatedFile?.v === 1)
7394
- ? advanceRunUndoProgress(run, result) : run;
7395
- Object.assign(run, WritebackSettlement.applySettlementTransition(
7396
- checkpoint,
7397
- WritebackSettlement.settleLegacyUndo({
7398
- run: checkpoint,
7399
- status: undoStatus,
7400
- result
7401
- })
7402
- ));
7403
- saveStateSoon();
7404
- refreshRunCardControls(runId);
7388
+ async function applyLegacyUndoSettlement(runId, undoStatus, result) {
7389
+ return getRunReviewController().applyLegacyUndoSettlement(runId, undoStatus, result);
7405
7390
  }
7406
7391
 
7407
7392
  function refreshRunCardControls(runId) {