codex-overleaf-link 2.5.1 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +149 -398
  2. package/README.zh-CN.md +153 -404
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +148 -3
  5. package/extension/src/backgroundSharedSessions.js +55 -2
  6. package/extension/src/backgroundUpdateCoordinator.js +128 -12
  7. package/extension/src/content/activeTurnControl.js +164 -7
  8. package/extension/src/content/composerAttachments.js +46 -10
  9. package/extension/src/content/composerPanel.js +92 -12
  10. package/extension/src/content/contentRuntime.js +1114 -321
  11. package/extension/src/content/contextTray.js +8 -2
  12. package/extension/src/content/contextUsageView.js +267 -0
  13. package/extension/src/content/diagnosticsController.js +77 -34
  14. package/extension/src/content/diagnosticsExport.js +1 -1
  15. package/extension/src/content/diagnosticsPanel.js +147 -42
  16. package/extension/src/content/generated/content.bundle.js +5761 -1237
  17. package/extension/src/content/generated/content.bundle.meta.json +6 -3
  18. package/extension/src/content/legacyGlobalRegistry.js +4 -0
  19. package/extension/src/content/localSkillsPanel.js +6 -4
  20. package/extension/src/content/markdownDomRenderer.js +5 -3
  21. package/extension/src/content/markdownText.js +53 -14
  22. package/extension/src/content/modelPicker.js +29 -19
  23. package/extension/src/content/nativeCompatibilityController.js +52 -19
  24. package/extension/src/content/otWarmMirror.js +1 -1
  25. package/extension/src/content/pageBridgeClient.js +7 -7
  26. package/extension/src/content/panelMaintenance.js +10 -2
  27. package/extension/src/content/panelRenderer.js +55 -28
  28. package/extension/src/content/pendingInputView.js +1 -1
  29. package/extension/src/content/projectSettingsCoordinator.js +30 -6
  30. package/extension/src/content/providerSettingsDialog.js +28 -19
  31. package/extension/src/content/recentProjects.js +30 -19
  32. package/extension/src/content/runActivitySummary.js +122 -14
  33. package/extension/src/content/runCompletionMeta.js +194 -6
  34. package/extension/src/content/runController.js +2 -1
  35. package/extension/src/content/runFailureNotice.js +8 -1
  36. package/extension/src/content/runPresence.js +18 -8
  37. package/extension/src/content/runQueueScheduler.js +27 -13
  38. package/extension/src/content/runRecoveryActions.js +2 -1
  39. package/extension/src/content/runResultActions.js +73 -24
  40. package/extension/src/content/runReviewController.js +102 -37
  41. package/extension/src/content/runTimelineView.js +224 -25
  42. package/extension/src/content/scopedPersistencePanelState.js +124 -46
  43. package/extension/src/content/selectionContextView.js +30 -3
  44. package/extension/src/content/sensitivePreflight.js +14 -1
  45. package/extension/src/content/sessionManager.js +49 -3
  46. package/extension/src/content/sessionMenuView.js +54 -18
  47. package/extension/src/content/sessionPersistence.js +25 -5
  48. package/extension/src/content/settingsPanel.js +15 -2
  49. package/extension/src/content/settingsWorkbench.js +127 -89
  50. package/extension/src/content/sharedSessionViewSync.js +13 -4
  51. package/extension/src/content/subagentActivityView.js +23 -7
  52. package/extension/src/content/updateNotice.js +60 -6
  53. package/extension/src/content/writebackOrchestrator.js +301 -72
  54. package/extension/src/content/writingStyleSettings.js +70 -17
  55. package/extension/src/page/compileBridge.js +193 -180
  56. package/extension/src/page/overleafCapabilities.js +4 -1
  57. package/extension/src/page/overleafEditor.js +12 -3
  58. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  59. package/extension/src/page/snapshotRouter.js +75 -66
  60. package/extension/src/page/textCoordinates.js +17 -1
  61. package/extension/src/page/textFileCreator.js +9 -1
  62. package/extension/src/page/treeOperations.js +200 -19
  63. package/extension/src/page/writebackRouter.js +172 -113
  64. package/extension/src/pageBridge.js +124 -73
  65. package/extension/src/popup.js +1 -1
  66. package/extension/src/shared/agentTranscript.js +372 -18
  67. package/extension/src/shared/auditRecords.js +3 -0
  68. package/extension/src/shared/compatibility.js +1 -1
  69. package/extension/src/shared/compileAdapter.js +58 -17
  70. package/extension/src/shared/composerAttachmentDraft.js +27 -7
  71. package/extension/src/shared/contextUsage.js +125 -0
  72. package/extension/src/shared/failureReasons.js +57 -3
  73. package/extension/src/shared/i18n.js +315 -29
  74. package/extension/src/shared/lineReferences.js +38 -31
  75. package/extension/src/shared/pageRpcContract.js +1 -1
  76. package/extension/src/shared/panelIcons.js +39 -0
  77. package/extension/src/shared/projectFiles.js +2 -1
  78. package/extension/src/shared/reviewing.js +130 -1
  79. package/extension/src/shared/runActivityModel.js +70 -4
  80. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  81. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  82. package/extension/src/shared/sessionState.js +63 -4
  83. package/extension/src/shared/sessionTitle.js +14 -1
  84. package/extension/src/shared/sharedSessionBridge.js +81 -13
  85. package/extension/src/shared/staleGuard.js +130 -63
  86. package/extension/src/shared/storageDb.js +35 -3
  87. package/extension/src/shared/storageMigration.js +111 -2
  88. package/extension/src/shared/storageRunActions.js +62 -8
  89. package/extension/src/shared/undoOperations.js +126 -28
  90. package/extension/src/shared/updateRuntimeIdentity.js +16 -2
  91. package/extension/src/shared/writebackSettlement.js +21 -3
  92. package/extension/styles/panel.css +1293 -549
  93. package/native-host/src/bootstrapUpdate.js +52 -16
  94. package/native-host/src/chatBridgeRequest.js +60 -6
  95. package/native-host/src/chatBridgeResponse.js +10 -1
  96. package/native-host/src/chatCompletionsBridge.js +29 -2
  97. package/native-host/src/codexPromptAssembly.js +86 -118
  98. package/native-host/src/codexSessionRunner.js +127 -17
  99. package/native-host/src/debugLog.js +27 -3
  100. package/native-host/src/index.js +42 -11
  101. package/native-host/src/localSkills.js +1 -1
  102. package/native-host/src/mirrorWorkspace.js +47 -3
  103. package/native-host/src/nativeEnvironment.js +27 -5
  104. package/native-host/src/providerContextError.js +20 -0
  105. package/native-host/src/providerRuntime.js +1 -0
  106. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  107. package/native-host/src/taskRunnerRuntime.js +28 -8
  108. package/native-host/src/textPatch.js +473 -377
  109. package/native-host/src/updateManager.js +144 -21
  110. package/native-host/src/writingStyleRuntime.js +1 -1
  111. package/package.json +5 -8
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Codex Overleaf Link",
4
- "version": "2.5.1",
4
+ "version": "2.6.5",
5
5
  "description": "Connect the active Overleaf project to a local Codex bridge.",
6
6
  "icons": {
7
7
  "16": "assets/icons/icon16.png",
@@ -66,6 +66,8 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
66
66
  'use strict';
67
67
 
68
68
  const HOST_NAME = 'com.codex.overleaf';
69
+ const NATIVE_PING_TIMEOUT_MS = 20000;
70
+ const NATIVE_REQUEST_TIMEOUT_MS = 60000;
69
71
  const MANAGED_UPDATE_STATE_KEY = 'codex-overleaf-managed-update-state-v1';
70
72
  const MANAGED_UPDATE_CONSENT_KEY = 'codex-overleaf-update-consent-v1';
71
73
  const MANAGED_UPDATE_TABS_KEY = 'codex-overleaf-managed-update-tabs-v1';
@@ -189,6 +191,17 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
189
191
  .catch(error => sendResponse({ ok: false, journals: [], error: getErrorMessage(error, 'Journal read failed.') }));
190
192
  return true;
191
193
  }
194
+ if (message?.type === 'codex-overleaf/run-journal/known') {
195
+ if (!isAllowedOverleafSender(sender)) {
196
+ sendResponse({ ok: false, known: [] });
197
+ return undefined;
198
+ }
199
+ knownRunJournals(Array.isArray(message.requestIds) ? message.requestIds.map(String).slice(0, 64) : [],
200
+ Array.isArray(message.owners) ? message.owners.slice(0, 64) : [])
201
+ .then(result => sendResponse({ ok: true, ...result }))
202
+ .catch(error => sendResponse({ ok: false, known: [], error: getErrorMessage(error, 'Journal read failed.') }));
203
+ return true;
204
+ }
192
205
  if (message?.type === 'codex-overleaf/run-journal/ack') {
193
206
  if (!isAllowedOverleafSender(sender)) {
194
207
  sendResponse({ ok: false });
@@ -266,6 +279,9 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
266
279
  return;
267
280
  }
268
281
  if (message.type === 'release') {
282
+ const current = ownerBindings.get(portConnection);
283
+ if (!current || ['requestId', 'projectKey', 'clientRunId', 'sessionId'].some(key => current[key] !== message[key])
284
+ || (current.browserOnly && current.ownerToken !== message.ownerToken)) return;
269
285
  ownerBindings.delete(portConnection);
270
286
  clearTimeout(ownerCancelTimers.get(requestId));
271
287
  ownerCancelTimers.delete(requestId);
@@ -278,9 +294,20 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
278
294
  documentId: String(portConnection.sender?.documentId || ''),
279
295
  tabId: portConnection.sender?.tab?.id,
280
296
  clientRunId: String(message.clientRunId || ''),
281
- sessionId: String(message.sessionId || '')
297
+ sessionId: String(message.sessionId || ''),
298
+ browserOnly: message.browserOnly === true,
299
+ ownerToken: String(message.ownerToken || '')
282
300
  };
301
+ if (binding.browserOnly && (!binding.projectKey || !binding.clientRunId || !binding.sessionId || !binding.ownerToken)) return;
302
+ if (binding.browserOnly && managedUpdateExecutionLocked) {
303
+ try { portConnection.postMessage?.({ type: 'bound', ...binding, ok: false }); } catch (_error) { /* port disconnected */ }
304
+ return;
305
+ }
283
306
  ownerBindings.set(portConnection, binding);
307
+ if (binding.browserOnly) {
308
+ try { portConnection.postMessage?.({ type: 'bound', ...binding }); } catch (_error) { /* port disconnected */ }
309
+ return;
310
+ }
284
311
  restoreRunJournal(binding).then(journal => {
285
312
  if (ownerBindings.get(portConnection) !== binding || !journal || runJournals.get(requestId) !== journal) return;
286
313
  clearTimeout(ownerCancelTimers.get(requestId));
@@ -295,7 +322,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
295
322
  portConnection.onDisconnect.addListener(() => {
296
323
  const binding = ownerBindings.get(portConnection);
297
324
  ownerBindings.delete(portConnection);
298
- if (!binding?.requestId) {
325
+ if (!binding?.requestId || binding.browserOnly) {
299
326
  return;
300
327
  }
301
328
  const timer = setTimeout(() => interruptOwnerlessRun(binding), 150);
@@ -373,11 +400,18 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
373
400
  method: request.method,
374
401
  retryClass: getNativeRetryClass(request.method),
375
402
  retryCount: 0,
403
+ deadlineTimer: null,
376
404
  nativePort: null,
377
405
  finalResponseReceived: false,
378
406
  eventForwarded: false
379
407
  };
380
408
  pending.set(id, pendingRequest);
409
+ const timeoutMs = getNativeRequestTimeoutMs(request.method);
410
+ if (timeoutMs) {
411
+ pendingRequest.deadlineTimer = setTimeout(() => {
412
+ expireNativeRequest(id, pendingRequest);
413
+ }, timeoutMs);
414
+ }
381
415
  if (request.method === 'codex.run') {
382
416
  createRunJournal(id, pendingRequest);
383
417
  }
@@ -390,12 +424,71 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
390
424
  });
391
425
  }
392
426
 
427
+ function getNativeRequestTimeoutMs(method) {
428
+ if (method === 'bridge.ping') return NATIVE_PING_TIMEOUT_MS;
429
+ switch (method) {
430
+ case 'mirror.status':
431
+ case 'codex.models':
432
+ case 'codex.providers.list':
433
+ case 'codex.providers.upsert':
434
+ case 'codex.providers.activate':
435
+ case 'codex.providers.clear-secret':
436
+ case 'codex.providers.delete':
437
+ case 'writing-style.get':
438
+ case 'writing-style.set':
439
+ case 'skills.list':
440
+ case 'asset.readChunk':
441
+ case 'update.status':
442
+ case 'update.check':
443
+ case 'update.canApply':
444
+ return NATIVE_REQUEST_TIMEOUT_MS;
445
+ default:
446
+ return 0;
447
+ }
448
+ }
449
+
450
+ function expireNativeRequest(id, pendingRequest) {
451
+ if (pending.get(id) !== pendingRequest || pendingRequest.finalResponseReceived) return;
452
+ pending.delete(id);
453
+ clearTimeout(pendingRequest.deadlineTimer);
454
+ pendingRequest.finalResponseReceived = true;
455
+ pendingRequest.resolve({
456
+ id,
457
+ ok: false,
458
+ error: {
459
+ code: 'native_request_timeout',
460
+ message: 'The local service did not respond.'
461
+ }
462
+ });
463
+ if (pendingRequest.method !== 'bridge.ping') return;
464
+ const failedPort = pendingRequest.nativePort;
465
+ if (!failedPort) return;
466
+ const hasActiveWork = Array.from(pending.values()).some(request => (
467
+ request.nativePort === failedPort && (
468
+ request.retryClass === 'safe_sync_retry' ||
469
+ (request.retryClass === 'no_silent_retry' && !getNativeRequestTimeoutMs(request.method))
470
+ )
471
+ ));
472
+ // A failed diagnostic ping must not abort execution or mirror writes.
473
+ if (hasActiveWork) return;
474
+ if (port === failedPort) port = null;
475
+ // A hung Host is retired without replaying requests or reconnecting until
476
+ // the next caller asks, only when no execution or mirror write owns it.
477
+ for (const [pendingId, interrupted] of Array.from(pending.entries())) {
478
+ if (interrupted.nativePort === failedPort) {
479
+ rejectInterruptedNativeRequest(pendingId, interrupted, 'The local service did not respond.');
480
+ }
481
+ }
482
+ try { failedPort.disconnect(); } catch (_error) { /* The port is already retired. */ }
483
+ }
484
+
393
485
  async function requestManagedInternal(payload) {
394
486
  if (managedBootstrapRuntime && ['update.status', 'update.confirm'].includes(payload?.method)) {
395
487
  payload = { ...payload, params: { ...payload.params,
396
488
  bootstrapRevision: String(globalThis.__CODEX_OVERLEAF_BOOTSTRAP_REVISION__ || '') } };
397
489
  }
398
- if (managedBootstrapRuntime && payload?.method === 'update.apply') {
490
+ if (managedBootstrapRuntime && (payload?.method === 'update.apply'
491
+ || (payload?.method === 'update.rollback' && payload?.params?.reasonCode === 'update_user_rollback'))) {
399
492
  const gate = await verifyManagedUpdateSafety();
400
493
  if (!gate.ok) {
401
494
  return gate;
@@ -450,6 +543,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
450
543
  }
451
544
  const pendingState = globalThis.CodexOverleafNativeBridge?.getPendingState?.() || {};
452
545
  const backgroundBlockers = Number(pendingState.executionRequests || 0) > 0
546
+ || Array.from(ownerBindings.values()).some(binding => binding.browserOnly)
453
547
  ? ['background_execution_pending']
454
548
  : [];
455
549
  const blockers = [
@@ -813,6 +907,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
813
907
  return;
814
908
  }
815
909
  pendingRequest.finalResponseReceived = true;
910
+ clearTimeout(pendingRequest.deadlineTimer);
816
911
  pending.delete(id);
817
912
  finalizeRunJournal(id, message).catch(() => {}).finally(() => {
818
913
  pendingRequest.resolve(message);
@@ -874,6 +969,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
874
969
 
875
970
  function rejectInterruptedNativeRequest(pendingId, pendingRequest, errorMessage) {
876
971
  pending.delete(pendingId);
972
+ clearTimeout(pendingRequest.deadlineTimer);
877
973
  finalizeRunJournal(pendingId, {
878
974
  ok: false,
879
975
  error: {
@@ -910,6 +1006,7 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
910
1006
  if (pending.size === 1) {
911
1007
  const [pendingId, pendingRequest] = pending.entries().next().value;
912
1008
  pendingRequest.finalResponseReceived = true;
1009
+ clearTimeout(pendingRequest.deadlineTimer);
913
1010
  pendingRequest.resolve({
914
1011
  ...message,
915
1012
  id: pendingId
@@ -995,6 +1092,9 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
995
1092
  clientRunId: String(params.clientRunId || ''),
996
1093
  sessionId: String(params.clientSessionId || ''),
997
1094
  task: sanitizeJournalString(params.task).slice(0, 12000),
1095
+ ...(params.operation === 'compact' ? {
1096
+ operation: 'compact', threadId: String(params.threadId || '').slice(0, 160)
1097
+ } : {}),
998
1098
  mode: String(params.mode || ''),
999
1099
  model: String(params.model || ''),
1000
1100
  ownerTabId: pendingRequest.tabId,
@@ -1176,6 +1276,51 @@ if (!globalThis.CodexOverleafBackgroundUpdateSafety) {
1176
1276
  return entries.slice(-16).map(([_key, value]) => value);
1177
1277
  }
1178
1278
 
1279
+ // Browser-only retries outlive their original Native journal. Protect only
1280
+ // a live port whose request/project/session/run matches the queried record.
1281
+ async function knownRunJournals(requestIds, owners = []) {
1282
+ await Promise.allSettled(Array.from(journalLoads.values(), load => load.promise));
1283
+ await Promise.allSettled(Array.from(journalWrites.values()));
1284
+ const storage = chrome.storage?.session;
1285
+ if (!storage) throw new Error('Run journal storage is unavailable.');
1286
+ const values = requestIds.length ? await storage.get(requestIds.map(id => RUN_JOURNAL_PREFIX + id)) : {};
1287
+ // A persisted startup snapshot may lag behind its owner's Native dispatch.
1288
+ // Keep that same project/session/run alive through the request-id transition.
1289
+ const matches = (candidate, binding) => (candidate?.preDispatch === true
1290
+ ? ['projectKey', 'clientRunId', 'sessionId'] : ['requestId', 'projectKey', 'clientRunId', 'sessionId'])
1291
+ .every(key => Boolean(candidate?.[key]) && candidate[key] === binding?.[key]);
1292
+ const known = requestIds.filter(id => runJournals.has(id) || Boolean(values?.[RUN_JOURNAL_PREFIX + id])
1293
+ || owners.some(candidate => candidate?.requestId === id && Array.from(ownerBindings.values()).some(binding => matches(candidate, binding))));
1294
+ const missing = owners.filter(candidate => requestIds.includes(candidate?.requestId) && !known.includes(candidate.requestId));
1295
+ if (!missing.length) return { known, ownersChecked: true, preDispatchChecked: true };
1296
+ // A worker restart drops ports before hidden pages can process reconnect
1297
+ // timers. The page's existing health receiver can still report its binding.
1298
+ const unknown = () => ({ known, ownersChecked: false, preDispatchChecked: false, retryAfterMs: 1000 });
1299
+ const tabs = await chrome.tabs.query({ url: MANAGED_OVERLEAF_MATCHES }).catch(() => null);
1300
+ if (!Array.isArray(tabs)) return unknown();
1301
+ const projects = new Set(missing.map(candidate => candidate.projectKey));
1302
+ const relevant = tabs.filter(tab => isManagedEditorTab(tab) && !tab.discarded && tab.status !== 'unloaded'
1303
+ && projects.has(new URL(tab.url).pathname.split('/')[2]));
1304
+ const responses = await Promise.all(relevant.map(async tab => {
1305
+ let timer;
1306
+ try {
1307
+ return await Promise.race([
1308
+ chrome.tabs.sendMessage(tab.id, { type: 'codex-overleaf/runtime-health-probe' }, { frameId: 0 }),
1309
+ new Promise(resolve => { timer = setTimeout(() => resolve(null), 1500); })
1310
+ ]);
1311
+ } catch (_error) { return null; }
1312
+ finally { clearTimeout(timer); }
1313
+ }));
1314
+ const needsStartupInventory = missing.some(candidate => candidate.preDispatch === true);
1315
+ if (responses.some(response => response?.ok !== true || ![1, 2].includes(response.runOwnerProtocol)
1316
+ || (needsStartupInventory && response.runOwnerProtocol !== 2) || !Array.isArray(response.runOwners))) return unknown();
1317
+ for (const candidate of missing) {
1318
+ if (responses.some(response => response.runOwners.some(binding => matches(candidate, binding)))
1319
+ || Array.from(ownerBindings.values()).some(binding => matches(candidate, binding))) known.push(candidate.requestId);
1320
+ }
1321
+ return { known, ownersChecked: true, preDispatchChecked: true };
1322
+ }
1323
+
1179
1324
  function pruneInMemoryRunJournals() {
1180
1325
  if (runJournals.size <= 16) {
1181
1326
  return;
@@ -16,6 +16,7 @@
16
16
  const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
17
17
  const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
18
18
  const failure = (code, message) => Object.assign(new Error(message), { code });
19
+ const conflict = () => failure('shared_session_conflict', 'The same session was edited in another tab. Refresh its history before retrying; the local draft was kept.');
19
20
  function part(value) {
20
21
  if (typeof value !== 'string' || !value.trim() || value.length > 256 || /[\0\r\n]/.test(value))
21
22
  throw failure('shared_store_scope_invalid', 'The shared session identity is invalid.');
@@ -71,7 +72,59 @@
71
72
  }
72
73
  return result;
73
74
  }
74
- throw failure('shared_session_conflict', 'The same session was edited in another tab. Refresh its history before retrying; the local draft was kept.');
75
+ throw conflict();
76
+ }
77
+
78
+ function titleValue(record) {
79
+ return { title: record.title || '', titleSource: record.titleSource === 'manual' ? 'manual' : 'auto' };
80
+ }
81
+ function manualTitleChange(base, next) {
82
+ return next.titleSource === 'manual' ? next.title !== base.title : base.titleSource === 'manual';
83
+ }
84
+ function mergeTitle(base, current, incoming) {
85
+ const before = titleValue(base), stored = titleValue(current), proposed = titleValue(incoming);
86
+ if (equal(proposed, before)) return stored;
87
+ if (equal(stored, before) || equal(stored, proposed)) return proposed;
88
+ // Auto derivations (including legacy normalization) carry no rename intent.
89
+ if (!manualTitleChange(before, proposed)) return stored;
90
+ if (!manualTitleChange(before, stored)) return proposed;
91
+ // The pair is one intent; merging its scalar members separately could
92
+ // combine a peer rename with this page's reset to auto.
93
+ throw conflict();
94
+ }
95
+ function draftConsumption(record) {
96
+ const marker = record.draftConsumption;
97
+ return record.task === '' && typeof marker?.task === 'string' && marker.task
98
+ && typeof marker.runId === 'string' && marker.runId
99
+ && (record.runs || []).some(run => run.id === marker.runId && !run.forkSnapshot
100
+ && run.runProjectId === record.projectId) ? marker : null;
101
+ }
102
+ function mergeSession(base, current, incoming) {
103
+ const currentConsumption = draftConsumption(current), incomingConsumption = draftConsumption(incoming);
104
+ const consumed = marker => marker && marker.task === base.task && !equal(marker, base.draftConsumption);
105
+ let task;
106
+ if (equal(incoming.task, base.task)) task = current.task;
107
+ else if (equal(current.task, base.task) || equal(current.task, incoming.task)) task = incoming.task;
108
+ else if (consumed(incomingConsumption)) task = current.task;
109
+ else if (consumed(currentConsumption)) task = incoming.task;
110
+ else task = mergeValue(base.task, current.task, incoming.task, 'task');
111
+
112
+ const result = { ...mergeTitle(base, current, incoming) };
113
+ if (task !== undefined) result.task = clone(task);
114
+ // Summaries are derived metadata, never a second draft edit. Prefer the
115
+ // stored summary when both sides have the winning task (including legacy forms).
116
+ const taskOwner = equal(task, current.task) ? current : incoming;
117
+ if (taskOwner.safeTaskSummary !== undefined) result.safeTaskSummary = clone(taskOwner.safeTaskSummary);
118
+ if (task === '' && (incomingConsumption || currentConsumption))
119
+ result.draftConsumption = clone(consumed(incomingConsumption)
120
+ ? incomingConsumption : currentConsumption || incomingConsumption);
121
+ const derived = new Set(['title', 'titleSource', 'task', 'safeTaskSummary', 'draftConsumption', 'sharedRevision']);
122
+ for (const key of new Set([...Object.keys(base), ...Object.keys(current), ...Object.keys(incoming)])) {
123
+ if (derived.has(key)) continue;
124
+ const value = mergeValue(base[key], current[key], incoming[key], key);
125
+ if (value !== undefined) Object.defineProperty(result, key, { value, enumerable: true, configurable: true, writable: true });
126
+ }
127
+ return result;
75
128
  }
76
129
 
77
130
  function createService(options = {}) {
@@ -232,7 +285,7 @@
232
285
  if (!base) throw failure('shared_session_conflict', 'Reload the session before updating existing history.');
233
286
  // Keep the established first-durable-review-decision policy.
234
287
  const reviewed = options.mergeSessionReviewState?.(candidate, existing.record, message.reviewRunIds || []) || candidate;
235
- next = mergeValue(base, existing.record, reviewed);
288
+ next = mergeSession(base, existing.record, reviewed);
236
289
  } else if (base && !importing) {
237
290
  throw failure('shared_session_deleted', 'The session no longer exists.');
238
291
  }
@@ -37,8 +37,11 @@
37
37
  'codex-overleaf/consent-update-cancel',
38
38
  'codex-overleaf/consent-update-recover',
39
39
  'codex-overleaf/consent-update-dismiss',
40
- 'codex-overleaf/consent-update-reload'
40
+ 'codex-overleaf/consent-update-reload',
41
+ 'codex-overleaf/consent-update-rollback'
41
42
  ]);
43
+ const USER_ROLLBACK_REASON = 'update_user_rollback';
44
+ const RECOVERABLE_TRANSACTION_STATES = new Set(['awaiting_health', 'bootstrap_pending', 'bootstrap_rolling_back']);
42
45
 
43
46
  const policy = root.CodexOverleafUpdateConsent;
44
47
  const revocation = root.CodexOverleafUpdateRevocation;
@@ -156,6 +159,8 @@
156
159
  return dismissCompletedUpdate();
157
160
  case 'codex-overleaf/consent-update-reload':
158
161
  return reloadInstalledRuntime();
162
+ case 'codex-overleaf/consent-update-rollback':
163
+ return restorePreviousVersion();
159
164
  default:
160
165
  throw codedError('unknown_update_action', 'Unknown update action.');
161
166
  }
@@ -174,14 +179,14 @@
174
179
  code: '', message: '', blocker: '', blockers: [], cooldownUntil: 0, postponeUntil: LEGACY_GUARD }, { operation });
175
180
  try {
176
181
  const result = await withTimeout(requestNative('update.check', {
177
- operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || ''
182
+ operationId: operation.id, currentVersion: currentVersion(), etag: currentState.etag || '', manual: manual === true
178
183
  }), CHECK_REQUEST_TIMEOUT_MS, codedError('update_check_timeout', 'The managed update check did not complete in time.'));
179
184
  assertOperation(operation);
180
185
  const checkedAt = Date.now();
181
186
  if (result.available) {
182
187
  const latestVersion = result.latestVersion || currentState.latestVersion;
183
188
  await setConsentState({ ...consent,
184
- ...(latestVersion !== consent.snoozedVersion ? { snoozedVersion: '', snoozedUntil: 0 } : {}),
189
+ ...(manual || latestVersion !== consent.snoozedVersion ? { snoozedVersion: '', snoozedUntil: 0 } : {}),
185
190
  lastPromptedVersion: latestVersion,
186
191
  lastPromptedAt: manual ? checkedAt : consent.lastPromptedAt
187
192
  }, { operation });
@@ -198,7 +203,8 @@
198
203
  currentVersion: result.currentVersion || currentVersion(), latestVersion: result.latestVersion,
199
204
  managed: true, etag: result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
200
205
  postponeUntil: LEGACY_GUARD, code: result.reason === 'prerelease_promotion_requires_manual_update'
201
- ? result.reason : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
206
+ ? result.reason : result.code === 'update_bootstrap_source_mismatch'
207
+ ? result.code : 'update_bootstrap_upgrade_required', message: '', cooldownUntil: 0,
202
208
  manualReason: result.manualInstall?.reason || {}, cancelRequested: false
203
209
  }, { operation });
204
210
  } else {
@@ -207,9 +213,11 @@
207
213
  latestVersion: result.latestVersion || result.currentVersion || currentVersion(),
208
214
  // A 304 without a verified Native candidate is a cache miss. Drop
209
215
  // its validator so the next normal check can obtain fresh bytes.
210
- etag: result.reason === 'not_modified' ? '' : result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
216
+ // A skipped version also needs fresh bytes when checked explicitly.
217
+ etag: ['not_modified', 'cooldown'].includes(result.reason) ? '' : result.etag || currentState.etag || '', lastCheckedAt: checkedAt,
211
218
  postponeUntil: LEGACY_GUARD, transactionId: '',
212
- code: result.reason === 'cooldown' ? 'update_cooldown' : '',
219
+ code: result.reason === 'cooldown'
220
+ ? (result.cooldownReason === USER_ROLLBACK_REASON ? 'update_skipped_after_rollback' : 'update_cooldown') : '',
213
221
  cooldownUntil: result.reason === 'cooldown' ? Number(result.cooldownUntil || 0) : 0,
214
222
  manualReason: {}, message: '', cancelRequested: false
215
223
  }, { operation });
@@ -508,7 +516,7 @@
508
516
  async function cancelUpdateCore() {
509
517
  const [state, consent] = await Promise.all([getUpdateState(), getConsentState()]);
510
518
  if (['applying', 'awaiting_health', 'rolling_back'].includes(state.state)
511
- || activeOperation?.phase === 'applying' || state.recoveryPending) {
519
+ || ['applying', 'rolling_back'].includes(activeOperation?.phase) || state.recoveryPending) {
512
520
  throw codedError('update_revoke_too_late', 'The update is being installed or recovered.');
513
521
  }
514
522
  const operation = activeOperation;
@@ -563,6 +571,7 @@
563
571
 
564
572
 
565
573
  function reconcileRecoveryState() {
574
+ if (activeOperation?.phase === 'rolling_back') return getUpdateState();
566
575
  if (recoveryPromise) return recoveryPromise;
567
576
  recoveryPromise = (async () => {
568
577
  if (!await waitForBootstrapOwnership()) return getUpdateState();
@@ -652,10 +661,35 @@
652
661
  if (transaction?.state === 'rolled_back' && status.installedAligned
653
662
  && status.activeVersion === transaction.sourceVersion
654
663
  && (!state.transactionId || transaction.id === state.transactionId)) {
655
- const restored = await setUpdateState({ state: 'rolled_back', currentVersion: transaction.sourceVersion,
664
+ const userRestore = transaction.reasonCode === USER_ROLLBACK_REASON;
665
+ if ((userRestore || state.code === 'update_rollback_unconfirmed')
666
+ && !root.CodexOverleafUpdateRuntimeIdentity?.verifiedRollback(status)) {
667
+ throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
668
+ }
669
+ if (userRestore) {
670
+ const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
671
+ const tabs = await chrome.tabs.query({ url: OVERLEAF_MATCHES });
672
+ await assertRecoveryOwner(recoveryOwner);
673
+ await chrome.storage.local.set({ [UPDATE_RELOAD_TABS_KEY]: [],
674
+ [MANUAL_RELOAD_TABS_KEY]: pending?.targetVersion === transaction.sourceVersion ? pending : {
675
+ targetVersion: transaction.sourceVersion, tabIds: tabs.filter(isUsableEditorTab).map(tab => tab.id)
676
+ } });
677
+ }
678
+ const value = { state: 'rolled_back', currentVersion: transaction.sourceVersion,
656
679
  transactionId: transaction.id, recoveryPending: false, cancelRequested: false,
657
680
  code: transaction.reasonCode || 'update_rolled_back', message: 'The previous version has been restored.'
658
- }, recoveryOptions);
681
+ };
682
+ const restored = userRestore
683
+ ? (await setUpdateAndConsentState({ ...value, latestVersion: transaction.targetVersion,
684
+ postponeUntil: LEGACY_GUARD, etag: '', cooldownUntil: 0
685
+ }, policy.normalizeConsentState({ snoozedVersion: transaction.targetVersion,
686
+ snoozedUntil: Date.now() + SNOOZE_MS }), recoveryOptions)).updateState
687
+ : await setUpdateState(value, recoveryOptions);
688
+ runtimeStatus = null;
689
+ if (userRestore) {
690
+ try { await requireReloadSafePoint(); } catch (_error) { return restored; }
691
+ }
692
+ if (recoveryOwner.generation !== operationGeneration) return getUpdateState();
659
693
  setTimeout(() => chrome.runtime.reload(), 0);
660
694
  return restored;
661
695
  }
@@ -765,9 +799,15 @@
765
799
  now: Date.now()
766
800
  });
767
801
  view.runtime = runtime;
802
+ // Native decides availability: a committed pair with its source still on
803
+ // disk, or any unsettled installation that recovery must finish anyway.
804
+ view.restorePrevious = !state.cancelRequested && !activeOperation
805
+ && (runtime.rollbackAvailable?.sourceVersion || (state.recoveryPending && state.transactionId))
806
+ ? { version: runtime.rollbackAvailable?.sourceVersion || '' } : null;
807
+ view.actions = { ...view.actions, restorePrevious: Boolean(view.restorePrevious) };
768
808
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
769
809
  const pagesPending = runtime.state === 'aligned' && pending?.targetVersion === runtime.installedVersion && pending.tabIds?.length;
770
- if (runtime.state === 'reload_required' || pagesPending) {
810
+ if (!state.recoveryPending && (runtime.state === 'reload_required' || pagesPending)) {
771
811
  view.showPanel = true;
772
812
  view.state = { ...state, state: pagesPending ? 'reload_tabs_required' : 'reload_required',
773
813
  currentVersion: runtime.extensionVersion, latestVersion: runtime.installedVersion,
@@ -784,9 +824,9 @@
784
824
  runtimeStatusRead = withTimeout(requestNative('update.status'), 4000,
785
825
  codedError('update_status_timeout', 'Installed version inspection timed out.')).then(status => {
786
826
  runtimeStatusAt = Date.now();
787
- runtimeStatus = root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
827
+ runtimeStatus = { ...root.CodexOverleafUpdateRuntimeIdentity.inspectInstalledRuntime(status, {
788
828
  extensionVersion: chrome.runtime.getManifest().version, runtimeVersion: currentVersion()
789
- });
829
+ }), rollbackAvailable: status.rollbackAvailable || null };
790
830
  return runtimeStatus;
791
831
  }).catch(() => ({ state: 'unknown' })).finally(() => { runtimeStatusRead = null; });
792
832
  return runtimeStatusRead;
@@ -808,6 +848,9 @@
808
848
  }
809
849
 
810
850
  async function reloadInstalledRuntime() {
851
+ if ((await getUpdateState()).recoveryPending) {
852
+ throw codedError('update_rollback_unconfirmed', 'Confirm update recovery before reloading.');
853
+ }
811
854
  const runtime = await readRuntimeStatus(true);
812
855
  if (runtime.state === 'aligned') { await finishManualRuntimeReload(); return getView(); }
813
856
  if (runtime.state !== 'reload_required') throw codedError('update_reload_unavailable', 'No verified installed update is awaiting reload.');
@@ -819,7 +862,80 @@
819
862
  return getView();
820
863
  }
821
864
 
865
+ // Returns a committed update, or an update whose installation or recovery is
866
+ // unsettled, to the source pair Native still holds. Native owns the decision;
867
+ // the browser only supplies the transaction it observed and reloads after an
868
+ // exact readback.
869
+ async function restorePreviousVersion() {
870
+ if (activeOperation || cancellationPromise) {
871
+ throw codedError('update_transaction_in_progress', 'Another update operation is in progress.');
872
+ }
873
+ const operation = beginOperation();
874
+ operation.phase = 'rolling_back';
875
+ try {
876
+ const status = await requestNative('update.status');
877
+ assertOperation(operation);
878
+ const transaction = status.transaction;
879
+ const committed = transaction?.state === 'committed' && status.rollbackAvailable?.transactionId === transaction.id;
880
+ if (!transaction?.id || (!committed && !RECOVERABLE_TRANSACTION_STATES.has(transaction.state))) {
881
+ throw codedError('update_rollback_unavailable', 'No installed update can be restored to its previous version.');
882
+ }
883
+ // Page refresh happens after restoration, so it waits for the same saved
884
+ // and idle point as a reload of an installed update.
885
+ const tabs = await requireReloadSafePoint();
886
+ assertOperation(operation);
887
+ const generation = operation.generation;
888
+ clearIdleRetryTimer();
889
+ await setUpdateState({ state: 'rolling_back', transactionId: transaction.id,
890
+ currentVersion: transaction.targetVersion, latestVersion: transaction.targetVersion,
891
+ recoveryPending: false, cancelRequested: false, operationId: '', code: '', message: ''
892
+ }, { observed: true, generation });
893
+ try {
894
+ // Old source runtimes consume this key through their saved/idle check.
895
+ // The fixed Bootstrap's legacy list would refresh tabs unconditionally.
896
+ await chrome.storage.local.set({ [UPDATE_RELOAD_TABS_KEY]: [],
897
+ [MANUAL_RELOAD_TABS_KEY]: { targetVersion: transaction.sourceVersion, tabIds: tabs.map(tab => tab.id) } });
898
+ assertOperation(operation);
899
+ const rolledBack = await requestNative('update.rollback', { transactionId: transaction.id,
900
+ expectedState: transaction.state, reasonCode: USER_ROLLBACK_REASON });
901
+ assertOperation(operation);
902
+ const readback = await requestNative('update.status');
903
+ assertOperation(operation);
904
+ if (rolledBack.transactionId !== transaction.id || rolledBack.state !== 'rolled_back'
905
+ || rolledBack.version !== transaction.sourceVersion || readback.transaction?.id !== transaction.id
906
+ || readback.transaction.sourceVersion !== transaction.sourceVersion
907
+ || readback.transaction.targetVersion !== transaction.targetVersion
908
+ || !root.CodexOverleafUpdateRuntimeIdentity?.verifiedRollback({ ...readback,
909
+ transaction: { ...readback.transaction, bootstrapRevision: transaction.bootstrapRevision } })
910
+ || !['extensionVersion', 'nativeVersion', 'extensionSha256', 'nativeSha256', 'bootstrapSha256']
911
+ .every(key => readback.restoredPair[key] === rolledBack.restoredPair?.[key])) {
912
+ throw codedError('update_rollback_unconfirmed', 'Rollback returned incomplete evidence.');
913
+ }
914
+ } catch (error) {
915
+ await setUpdateState({ state: 'failed', transactionId: transaction.id, recoveryPending: true,
916
+ cancelRequested: false, code: safeCode(error), message: safeMessage(error)
917
+ }, { observed: true, generation }).catch(() => {});
918
+ throw error;
919
+ }
920
+ await setUpdateAndConsentState({ state: 'rolled_back', transactionId: transaction.id,
921
+ currentVersion: transaction.sourceVersion, latestVersion: transaction.targetVersion,
922
+ recoveryPending: false, cancelRequested: false, blocker: '', blockers: [],
923
+ postponeUntil: LEGACY_GUARD, etag: '', cooldownUntil: 0,
924
+ code: USER_ROLLBACK_REASON, message: 'You restored the previous version.'
925
+ }, policy.normalizeConsentState({ snoozedVersion: transaction.targetVersion,
926
+ snoozedUntil: Date.now() + SNOOZE_MS }), { observed: true, generation });
927
+ runtimeStatus = null;
928
+ // The disk restore is complete even if new work now prevents a reload.
929
+ // Keep the existing Reload action available without attempting rollback again.
930
+ try { await requireReloadSafePoint(); } catch (_error) { return getView(); }
931
+ assertOperation(operation);
932
+ setTimeout(() => chrome.runtime.reload(), 0);
933
+ return getView();
934
+ } finally { finishOperation(operation); }
935
+ }
936
+
822
937
  async function finishManualRuntimeReload() {
938
+ if ((await getUpdateState()).recoveryPending) return;
823
939
  const pending = (await chrome.storage.local.get(MANUAL_RELOAD_TABS_KEY))?.[MANUAL_RELOAD_TABS_KEY];
824
940
  if (!pending || !Array.isArray(pending.tabIds)) return;
825
941
  const runtime = await readRuntimeStatus(true);