codex-overleaf-link 2.5.0 → 2.6.0

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 (189) hide show
  1. package/README.md +155 -392
  2. package/README.zh-CN.md +154 -393
  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 +283 -55
  9. package/extension/src/backgroundBootstrapMigration.js +181 -0
  10. package/extension/src/backgroundSharedSessions.js +129 -16
  11. package/extension/src/backgroundUpdateCoordinator.js +242 -29
  12. package/extension/src/backgroundUpdateSafety.js +173 -0
  13. package/extension/src/content/activeTurnControl.js +171 -8
  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 +104 -47
  17. package/extension/src/content/composerPanel.js +110 -12
  18. package/extension/src/content/contentRuntime.js +1555 -926
  19. package/extension/src/content/contextTray.js +8 -2
  20. package/extension/src/content/contextUsageView.js +267 -0
  21. package/extension/src/content/diagnosticsController.js +122 -96
  22. package/extension/src/content/diagnosticsExport.js +19 -5
  23. package/extension/src/content/diagnosticsPanel.js +48 -0
  24. package/extension/src/content/directoryCleanupRecovery.js +54 -0
  25. package/extension/src/content/generated/content.bundle.js +11077 -3412
  26. package/extension/src/content/generated/content.bundle.meta.json +14 -3
  27. package/extension/src/content/globalPreferencesController.js +1 -1
  28. package/extension/src/content/legacyGlobalRegistry.js +10 -0
  29. package/extension/src/content/localSkillsPanel.js +104 -14
  30. package/extension/src/content/markdownDomRenderer.js +5 -3
  31. package/extension/src/content/markdownText.js +47 -11
  32. package/extension/src/content/modelPicker.js +8 -4
  33. package/extension/src/content/nativeCompatibilityController.js +74 -30
  34. package/extension/src/content/otWarmMirror.js +5 -6
  35. package/extension/src/content/otWarmMirrorController.js +17 -20
  36. package/extension/src/content/pageBridgeClient.js +17 -4
  37. package/extension/src/content/panelMaintenance.js +46 -14
  38. package/extension/src/content/panelRenderer.js +57 -27
  39. package/extension/src/content/pendingInputView.js +1 -1
  40. package/extension/src/content/projectProviderSelection.js +13 -4
  41. package/extension/src/content/projectSessionCleanup.js +109 -62
  42. package/extension/src/content/projectSettingsCoordinator.js +67 -6
  43. package/extension/src/content/providerSettingsCoordinator.js +46 -8
  44. package/extension/src/content/providerSettingsDialog.js +73 -14
  45. package/extension/src/content/recentProjects.js +15 -8
  46. package/extension/src/content/runActivitySummary.js +160 -18
  47. package/extension/src/content/runCompletionMeta.js +532 -0
  48. package/extension/src/content/runController.js +39 -15
  49. package/extension/src/content/runFailureNotice.js +29 -13
  50. package/extension/src/content/runGuidanceController.js +72 -27
  51. package/extension/src/content/runGuidanceView.js +59 -10
  52. package/extension/src/content/runPresence.js +18 -8
  53. package/extension/src/content/runQueueScheduler.js +157 -47
  54. package/extension/src/content/runRecoveryActions.js +147 -0
  55. package/extension/src/content/runResultActions.js +28 -143
  56. package/extension/src/content/runReviewController.js +633 -0
  57. package/extension/src/content/runScrollLayout.js +21 -2
  58. package/extension/src/content/runTimelineView.js +367 -175
  59. package/extension/src/content/scopedPersistencePanelState.js +134 -20
  60. package/extension/src/content/selectionContextView.js +1 -1
  61. package/extension/src/content/sensitivePreflight.js +153 -0
  62. package/extension/src/content/sessionForkController.js +80 -17
  63. package/extension/src/content/sessionManager.js +50 -4
  64. package/extension/src/content/sessionMenuView.js +54 -18
  65. package/extension/src/content/sessionPersistence.js +25 -5
  66. package/extension/src/content/settingsPanel.js +22 -5
  67. package/extension/src/content/settingsWorkbench.js +132 -89
  68. package/extension/src/content/sharedSessionViewSync.js +49 -11
  69. package/extension/src/content/subagentActivityView.js +23 -7
  70. package/extension/src/content/updateIdle.js +4 -1
  71. package/extension/src/content/updateNotice.js +118 -12
  72. package/extension/src/content/updateSafetyReceiver.js +58 -0
  73. package/extension/src/content/writebackController.js +53 -4
  74. package/extension/src/content/writebackOrchestrator.js +116 -94
  75. package/extension/src/content/writingStyleSettings.js +35 -11
  76. package/extension/src/page/binaryAssetUploader.js +136 -14
  77. package/extension/src/page/compileBridge.js +193 -180
  78. package/extension/src/page/fileMover.js +256 -0
  79. package/extension/src/page/overleafCapabilities.js +4 -1
  80. package/extension/src/page/ownedDirectoryCleanup.js +94 -0
  81. package/extension/src/page/referenceProjects.js +1 -0
  82. package/extension/src/page/reviewContext.js +113 -0
  83. package/extension/src/page/saveState.js +6 -4
  84. package/extension/src/page/snapshotRouter.js +84 -39
  85. package/extension/src/page/textCoordinates.js +17 -1
  86. package/extension/src/page/textFileCreator.js +114 -26
  87. package/extension/src/page/trackedChangeCapture.js +12 -2
  88. package/extension/src/page/trackedChangeOwnership.js +19 -1
  89. package/extension/src/page/trackedChangeReplay.js +41 -21
  90. package/extension/src/page/trackedChangesLifecycle.js +217 -199
  91. package/extension/src/page/treeOperations.js +7 -10
  92. package/extension/src/page/updateSafetyProbe.js +41 -0
  93. package/extension/src/page/writebackReceiptJournal.js +2 -0
  94. package/extension/src/page/writebackRouter.js +103 -155
  95. package/extension/src/pageBridge.js +126 -69
  96. package/extension/src/popup.js +62 -10
  97. package/extension/src/shared/agentTranscript.js +373 -17
  98. package/extension/src/shared/auditRecords.js +4 -0
  99. package/extension/src/shared/compatibility.js +22 -6
  100. package/extension/src/shared/compileAdapter.js +129 -66
  101. package/extension/src/shared/composerAttachmentDraft.js +156 -0
  102. package/extension/src/shared/contextUsage.js +125 -0
  103. package/extension/src/shared/directoryCleanupProof.js +43 -0
  104. package/extension/src/shared/failureReasons.js +349 -4
  105. package/extension/src/shared/globalPreferences.js +30 -13
  106. package/extension/src/shared/governanceRules.js +4 -37
  107. package/extension/src/shared/i18n.js +590 -24
  108. package/extension/src/shared/lineReferences.js +38 -31
  109. package/extension/src/shared/managedUpdateProjection.js +2 -2
  110. package/extension/src/shared/nativeRequestIdentity.js +1 -0
  111. package/extension/src/shared/pageRpcContract.js +5 -4
  112. package/extension/src/shared/panelIcons.js +39 -0
  113. package/extension/src/shared/projectFiles.js +21 -44
  114. package/extension/src/shared/providerProfiles.js +5 -3
  115. package/extension/src/shared/reviewing.js +130 -1
  116. package/extension/src/shared/runActivityModel.js +70 -4
  117. package/extension/src/shared/runExecutionSnapshot.js +13 -0
  118. package/extension/src/shared/runInputQueue.js +48 -5
  119. package/extension/src/shared/selectionContext.js +12 -3
  120. package/extension/src/shared/sessionState.js +132 -28
  121. package/extension/src/shared/sessionTitle.js +14 -1
  122. package/extension/src/shared/settlementFacts.js +10 -2
  123. package/extension/src/shared/sharedSessionBridge.js +118 -27
  124. package/extension/src/shared/sharedSessionTransfer.js +140 -0
  125. package/extension/src/shared/staleGuard.js +166 -52
  126. package/extension/src/shared/storageDb.js +90 -26
  127. package/extension/src/shared/storageMigration.js +78 -112
  128. package/extension/src/shared/storageRunActions.js +267 -30
  129. package/extension/src/shared/subagentActivityModel.js +1 -1
  130. package/extension/src/shared/undoOperations.js +253 -32
  131. package/extension/src/shared/updateConsent.js +6 -4
  132. package/extension/src/shared/updateRuntimeIdentity.js +17 -3
  133. package/extension/src/shared/writebackIntent.js +16 -0
  134. package/extension/src/shared/writebackOperationEvidence.js +4 -15
  135. package/extension/src/shared/writebackSettlement.js +58 -40
  136. package/extension/styles/panel.css +1123 -493
  137. package/native-host/src/anthropicBridgeRequest.js +4 -2
  138. package/native-host/src/anthropicBridgeResponse.js +6 -2
  139. package/native-host/src/anthropicMessagesBridge.js +24 -16
  140. package/native-host/src/bootstrapMigration.js +296 -0
  141. package/native-host/src/bootstrapMigrationContract.js +101 -0
  142. package/native-host/src/bootstrapUpdate.js +269 -0
  143. package/native-host/src/bootstrapUpdateWatchdog.js +44 -0
  144. package/native-host/src/chatBridgeRequest.js +60 -6
  145. package/native-host/src/chatBridgeResponse.js +20 -1
  146. package/native-host/src/chatCompletionsBridge.js +48 -15
  147. package/native-host/src/codexConfigPaths.js +106 -0
  148. package/native-host/src/codexHome.js +66 -73
  149. package/native-host/src/codexHomeScope.js +124 -0
  150. package/native-host/src/codexInheritedAuth.js +90 -0
  151. package/native-host/src/codexLocalSkills.js +301 -0
  152. package/native-host/src/codexPromptAssembly.js +114 -127
  153. package/native-host/src/codexProviderTest.js +24 -26
  154. package/native-host/src/codexSessionRunner.js +234 -198
  155. package/native-host/src/codexSessionTiming.js +18 -8
  156. package/native-host/src/codexSkillIsolation.js +170 -0
  157. package/native-host/src/codexThreadFork.js +19 -3
  158. package/native-host/src/index.js +19 -7
  159. package/native-host/src/managedInstall.js +110 -35
  160. package/native-host/src/managedInstallRecovery.js +140 -0
  161. package/native-host/src/managedRuntimeEvidence.js +57 -0
  162. package/native-host/src/mirrorWorkspace.js +92 -22
  163. package/native-host/src/nativeDoctor.js +15 -0
  164. package/native-host/src/nativeHelperLifecycle.js +21 -0
  165. package/native-host/src/nativeHostRegistration.js +33 -0
  166. package/native-host/src/nativeRunLifecycle.js +71 -0
  167. package/native-host/src/processStopBarrier.js +42 -7
  168. package/native-host/src/projectPathPlatform.js +30 -0
  169. package/native-host/src/providerContextError.js +20 -0
  170. package/native-host/src/providerHttp.js +80 -0
  171. package/native-host/src/providerProfile.js +4 -2
  172. package/native-host/src/providerReasoning.js +2 -7
  173. package/native-host/src/providerRuntime.js +1 -0
  174. package/native-host/src/providerStore.js +72 -14
  175. package/native-host/src/responsesPassthroughBridge.js +266 -52
  176. package/native-host/src/runtimeInstaller.js +21 -1
  177. package/native-host/src/selectionScope.js +36 -8
  178. package/native-host/src/subagentAdmission.js +26 -0
  179. package/native-host/src/subagentBroker.js +79 -66
  180. package/native-host/src/subagentTelemetry.js +19 -10
  181. package/native-host/src/subagentWorkspacePath.js +5 -2
  182. package/native-host/src/taskRunnerRuntime.js +92 -57
  183. package/native-host/src/textPatch.js +473 -377
  184. package/native-host/src/tomlStatements.js +140 -0
  185. package/native-host/src/updateManager.js +409 -46
  186. package/native-host/src/writingStyleRuntime.js +57 -29
  187. package/native-host/src/writingStyleStore.js +73 -0
  188. package/package.json +5 -8
  189. package/scripts/install-managed.mjs +1 -0
@@ -0,0 +1,181 @@
1
+ (function initBootstrapMigration(root) {
2
+ 'use strict';
3
+
4
+ const UPDATE_STATE_KEY = 'codex-overleaf-managed-update-state-v1';
5
+ const RETRY_ALARM = 'codex-overleaf-bootstrap-migration-retry';
6
+ const RETRY_MS = 4000;
7
+ const MAX_WAIT_MS = 2 * 60 * 1000;
8
+ let readyPromise;
9
+ let lockedOwner;
10
+ let setExecutionLocked;
11
+
12
+ function init(options) {
13
+ if (readyPromise) return readyPromise;
14
+ readyPromise = prepare(options);
15
+ return readyPromise;
16
+ }
17
+
18
+ async function prepare(options) {
19
+ let transaction;
20
+ let reloadProtected = false;
21
+ let rollbackState = 'bootstrap_pending';
22
+ const revision = String(root.__CODEX_OVERLEAF_BOOTSTRAP_REVISION__ || '');
23
+ const request = async (method, params = {}) => {
24
+ const response = await options.request({ id: crypto.randomUUID(), method,
25
+ params: { ...params, bootstrapRevision: revision } });
26
+ if (!response?.ok) throw Object.assign(new Error(response?.error?.message || 'Managed update request failed.'),
27
+ { code: response?.error?.code || 'update_health_failed' });
28
+ return response.result || {};
29
+ };
30
+ try {
31
+ const status = await request('update.status');
32
+ transaction = status.transaction;
33
+ if (transaction?.state !== 'bootstrap_pending') return { continueInitialization: true };
34
+ options.setExecutionLocked(true);
35
+ lockedOwner = { id: transaction.id, targetVersion: transaction.targetVersion, revision };
36
+ setExecutionLocked = options.setExecutionLocked;
37
+ if (!transaction.id || transaction.targetVersion !== root.CodexOverleafCompatibility?.BUILD_TARGET_VERSION) {
38
+ throw codedError('The running update does not match the pending Bootstrap transaction.');
39
+ }
40
+ reloadProtected = await options.protectReloadTabs(transaction);
41
+ if (!reloadProtected) throw codedError('The previous page refresh list could not be protected.');
42
+ await writeState({ state: 'awaiting_health', transactionId: transaction.id,
43
+ latestVersion: transaction.targetVersion, code: '', message: '' });
44
+
45
+ // The first reload may leave old content contexts alive. Recheck the same
46
+ // idle/save contract before replacing their loader and reloading again.
47
+ const deadline = Date.now() + MAX_WAIT_MS;
48
+ while (true) {
49
+ const gate = await options.verifySafety({ transactionId: transaction.id, targetVersion: transaction.targetVersion,
50
+ sourceVersion: transaction.sourceVersion, expectedState: 'bootstrap_pending' });
51
+ if (gate?.ok) break;
52
+ if (Date.now() >= deadline) throw codedError('The Bootstrap update did not reach a safe point.');
53
+ await writeState({ blockers: gate?.error?.blockers || ['busy'] });
54
+ await waitForRetry();
55
+ const refreshed = await request('update.status');
56
+ if (refreshed.transaction?.id !== transaction.id || refreshed.transaction?.state !== 'bootstrap_pending') {
57
+ throw Object.assign(codedError('The pending Bootstrap transaction changed before activation.'), { restoredStatus: refreshed });
58
+ }
59
+ }
60
+
61
+ const result = await request('update.bootstrap', { transactionId: transaction.id });
62
+ if (result.transactionId !== transaction.id || result.targetVersion !== transaction.targetVersion) {
63
+ throw codedError('The Bootstrap activation result does not match the pending update.');
64
+ }
65
+ await writeState({ blockers: [], blocker: '' });
66
+ if (result.state === 'reload_required') {
67
+ // Only one reload per worker. Native owns the persistent attempt limit
68
+ // so a broken target cannot create a loop across worker restarts.
69
+ chrome.runtime.reload();
70
+ return { continueInitialization: false, state: result.state };
71
+ }
72
+ if (result.state !== 'awaiting_health' || revision !== '3.1' || result.bootstrapRevision !== revision) {
73
+ throw codedError('The updated Bootstrap did not provide its loaded revision.');
74
+ }
75
+ rollbackState = 'awaiting_health';
76
+ if (!await options.releaseReloadTabs(transaction, 'awaiting_health')) throw codedError('The original page identities could not be confirmed.');
77
+ return { continueInitialization: true, state: result.state };
78
+ } catch (error) {
79
+ if (!transaction || transaction.state !== 'bootstrap_pending') return { continueInitialization: true };
80
+ if (!reloadProtected) {
81
+ await writeState({ state: 'failed', recoveryPending: true, code: 'update_recovery_pending' }).catch(() => {});
82
+ return { continueInitialization: false, state: 'failed' };
83
+ }
84
+ const code = /^[a-z0-9_]{1,80}$/.test(error?.code || '') ? error.code : 'update_health_failed';
85
+ await writeState({ state: 'rolling_back', code }).catch(() => {});
86
+ let restored = false;
87
+ try {
88
+ let status = error.restoredStatus;
89
+ let rollback = status && { transactionId: status.transaction?.id, state: status.transaction?.state,
90
+ version: status.activeVersion, restoredPair: status.restoredPair };
91
+ if (!hasRestoredSource(transaction, rollback, status)) {
92
+ rollback = await request('update.rollback', { transactionId: transaction.id,
93
+ expectedState: rollbackState, reasonCode: code });
94
+ status = await request('update.status');
95
+ }
96
+ if (!hasRestoredSource(transaction, rollback, status)) throw codedError('Bootstrap rollback is unconfirmed.');
97
+ await writeState({ state: 'rolled_back', currentVersion: transaction.sourceVersion,
98
+ transactionId: transaction.id, recoveryPending: false, blockers: [], blocker: '', code });
99
+ restored = true;
100
+ while (true) {
101
+ try {
102
+ // A new document must never inherit the old document's refresh
103
+ // authorization. The source worker sees an empty legacy list;
104
+ // the guarded evidence remains for a later natural/user refresh.
105
+ if (await options.hasReplacedReloadDocument(transaction)) break;
106
+ const gate = await options.verifySafety({ transactionId: transaction.id, targetVersion: transaction.targetVersion,
107
+ sourceVersion: transaction.sourceVersion, expectedState: 'rolled_back' });
108
+ if (gate?.ok && await options.releaseReloadTabs(transaction, 'rolled_back')) break;
109
+ } catch (_) { /* Restored files stay restored while a page probe retries. */ }
110
+ await waitForRetry();
111
+ }
112
+ chrome.runtime.reload();
113
+ return { continueInitialization: false, state: 'rolled_back' };
114
+ } catch (_rollbackError) {
115
+ if (restored) return { continueInitialization: false, state: 'rolled_back' };
116
+ await writeState({ state: 'failed', recoveryPending: true, transactionId: transaction.id,
117
+ code: 'update_recovery_pending', message: 'The installed update result has not been confirmed.' }).catch(() => {});
118
+ return { continueInitialization: false, state: 'failed' };
119
+ }
120
+ } finally {
121
+ await chrome.alarms?.clear?.(RETRY_ALARM).catch?.(() => {});
122
+ }
123
+ }
124
+
125
+ async function writeState(patch) {
126
+ const stored = await chrome.storage.local.get(UPDATE_STATE_KEY);
127
+ const state = root.CodexOverleafManagedUpdateProjection.transition(stored[UPDATE_STATE_KEY] || {}, patch,
128
+ { currentVersion: chrome.runtime.getManifest().version });
129
+ await chrome.storage.local.set({ [UPDATE_STATE_KEY]: state });
130
+ }
131
+
132
+ function waitForRetry() {
133
+ return new Promise(resolve => {
134
+ let settled = false;
135
+ let timer;
136
+ const finish = () => {
137
+ if (settled) return;
138
+ settled = true;
139
+ clearTimeout(timer);
140
+ chrome.alarms?.onAlarm?.removeListener?.(onAlarm);
141
+ resolve();
142
+ };
143
+ const onAlarm = alarm => { if (alarm?.name === RETRY_ALARM) finish(); };
144
+ chrome.alarms?.onAlarm?.addListener?.(onAlarm);
145
+ chrome.alarms?.create?.(RETRY_ALARM, { delayInMinutes: 0.5 });
146
+ timer = setTimeout(finish, RETRY_MS);
147
+ });
148
+ }
149
+
150
+ function hasRestoredSource(transaction, rollback, status) {
151
+ const pair = status?.restoredPair;
152
+ return rollback?.transactionId === transaction.id && rollback.state === 'rolled_back'
153
+ && rollback.version === transaction.sourceVersion && status?.transaction?.id === transaction.id
154
+ && status.transaction.state === 'rolled_back' && status.activeVersion === transaction.sourceVersion
155
+ && status.installedAligned === true && pair?.extensionVersion === transaction.sourceVersion
156
+ && pair?.nativeVersion === transaction.sourceVersion
157
+ && /^[0-9a-f]{64}$/.test(pair.extensionSha256 || '') && /^[0-9a-f]{64}$/.test(pair.nativeSha256 || '')
158
+ && /^[0-9a-f]{64}$/.test(pair.bootstrapSha256 || '')
159
+ && ['extensionVersion', 'nativeVersion', 'extensionSha256', 'nativeSha256', 'bootstrapSha256']
160
+ .every(key => pair[key] === rollback.restoredPair?.[key]);
161
+ }
162
+
163
+ function codedError(message) {
164
+ return Object.assign(new Error(message), { code: 'update_health_failed' });
165
+ }
166
+
167
+ function observeConfirmation(params, response) {
168
+ if (!lockedOwner || !response?.ok || response.result?.state !== 'committed'
169
+ || response.result.version !== lockedOwner.targetVersion || params?.transactionId !== lockedOwner.id
170
+ || params.extensionVersion !== lockedOwner.targetVersion || params.nativeVersion !== lockedOwner.targetVersion
171
+ || params.bootstrapRevision !== lockedOwner.revision || lockedOwner.revision !== '3.1') return;
172
+ lockedOwner = null;
173
+ setExecutionLocked(false);
174
+ }
175
+
176
+ root.CodexOverleafBootstrapMigration = Object.freeze({
177
+ init,
178
+ observeConfirmation,
179
+ ready: () => readyPromise || Promise.resolve({ continueInitialization: true })
180
+ });
181
+ })(globalThis);
@@ -1,11 +1,13 @@
1
1
  (function (root, factory) {
2
- const api = factory();
2
+ const api = factory(typeof module === 'object' && module.exports
3
+ ? require('./shared/sharedSessionTransfer') : root.CodexOverleafSharedSessionTransfer);
3
4
  if (typeof module === 'object' && module.exports) module.exports = api;
4
5
  if (root) root.CodexOverleafSharedSessionsBackground = api;
5
- })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
6
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function (Transfer) {
6
7
  'use strict';
7
8
  const MESSAGE = 'codex-overleaf/shared-sessions-v1';
8
9
  const LOCK_PORT = 'codex-overleaf/shared-session-lock-v1';
10
+ const REVIEW_LEASE_PREFIX = 'codex-overleaf-review-lease-v1:';
9
11
  const CHANGE_KEY = 'codex-overleaf-shared-sessions-changed-v1';
10
12
  const HOSTS = ['overleaf.com', 'www.overleaf.com', 'cn.overleaf.com'];
11
13
  const META_PREFIX = 'codex-overleaf-scoped-persistence-v1:';
@@ -14,16 +16,18 @@
14
16
  const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
15
17
  const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
16
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.');
17
20
  function part(value) {
18
21
  if (typeof value !== 'string' || !value.trim() || value.length > 256 || /[\0\r\n]/.test(value))
19
22
  throw failure('shared_store_scope_invalid', 'The shared session identity is invalid.');
20
23
  return value;
21
24
  }
22
25
  function scope(input) {
23
- return { accountScopeId: part(input?.accountScopeId), projectId: typeof input?.projectId === 'string' ? input.projectId : '' };
26
+ return { accountScopeId: part(input?.accountScopeId), projectId: typeof input?.projectId === 'string' ? input.projectId : '', ...(input?.runId ? { runId: part(input.runId) } : {}) };
24
27
  }
25
28
  function keyFor(kind, value) {
26
- return kind === 'preferences' ? 'preferences'
29
+ return kind === 'review' ? 'review:' + JSON.stringify([value.accountScopeId, part(value.projectId), part(value.runId)])
30
+ : kind === 'preferences' ? 'preferences'
27
31
  : 'state:' + encodeURIComponent(value.accountScopeId);
28
32
  }
29
33
  function metaKey(value) {
@@ -68,7 +72,59 @@
68
72
  }
69
73
  return result;
70
74
  }
71
- 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;
72
128
  }
73
129
 
74
130
  function createService(options = {}) {
@@ -137,7 +193,7 @@
137
193
  const stored = item.value;
138
194
  if (value.projectId && stored.projectId !== value.projectId) { item.continue(); return; }
139
195
  const record = { ...stored.record, sharedRevision: stored.revision };
140
- const size = JSON.stringify(record).length * 2;
196
+ const size = Transfer.byteLength(JSON.stringify(record));
141
197
  if (result.length && (result.length >= 8 || bytes + size > MAX_BYTES)) {
142
198
  resolve({ records: result, next: last }); return;
143
199
  }
@@ -162,10 +218,9 @@
162
218
  appliedOperations: [], trackedChangeCaptures: [], undoStatus: '' })) };
163
219
  }
164
220
  async function writeMany(message, value, importing, origin, sender) {
165
- const entries = (importing ? [{ record: message.record }] : message.entries || [])
166
- .map(entry => ({ record: clone(entry.record), base: clone(entry.base) }));
167
- if (JSON.stringify(entries).length * 2 > MAX_BYTES * 3)
168
- throw failure('shared_session_too_large', 'The session batch exceeds the shared storage transfer limit.');
221
+ // Reject a lost owner before doing any bounded decompression work.
222
+ ensureLock(message, value, sender);
223
+ const entries = (await Transfer.decode(message)).map(entry => ({ record: clone(entry.record), base: clone(entry.base) }));
169
224
  for (const { record, base } of entries) {
170
225
  if (!record || record.accountScopeId !== value.accountScopeId || record.projectId !== value.projectId)
171
226
  throw failure('shared_store_scope_mismatch', 'The session does not belong to the captured account and project.');
@@ -197,6 +252,14 @@
197
252
  putAt(entry.record, entry.base);
198
253
  }
199
254
  };
255
+ function appendResult(record, revision) {
256
+ // Merge may grow beyond either input. Reject inside this transaction,
257
+ // before returning success or committing any sibling record.
258
+ Transfer.validateRecord(record);
259
+ const result = { ...record, sharedRevision: revision };
260
+ Transfer.validateResponse([...results, result]);
261
+ results.push(result);
262
+ }
200
263
  function putAt(candidate, base, copy = false) {
201
264
  const key = [value.accountScopeId, candidate.id];
202
265
  const tombstone = deleted.get(key);
@@ -214,7 +277,7 @@
214
277
  let next = candidate;
215
278
  if (importing && existing) {
216
279
  if (equal(existing.record, candidate) || copy) {
217
- results.push({ ...existing.record, sharedRevision: existing.revision }); return;
280
+ appendResult(existing.record, existing.revision); return;
218
281
  }
219
282
  putAt(recoveryCopy(candidate, origin), null, true); return;
220
283
  }
@@ -222,14 +285,14 @@
222
285
  if (!base) throw failure('shared_session_conflict', 'Reload the session before updating existing history.');
223
286
  // Keep the established first-durable-review-decision policy.
224
287
  const reviewed = options.mergeSessionReviewState?.(candidate, existing.record, message.reviewRunIds || []) || candidate;
225
- next = mergeValue(base, existing.record, reviewed);
288
+ next = mergeSession(base, existing.record, reviewed);
226
289
  } else if (base && !importing) {
227
290
  throw failure('shared_session_deleted', 'The session no longer exists.');
228
291
  }
229
292
  const revision = (existing?.revision || 0) + 1;
293
+ appendResult(next, revision);
230
294
  store.put({ accountScopeId: value.accountScopeId, projectId: value.projectId,
231
295
  id: next.id, record: next, revision });
232
- results.push({ ...next, sharedRevision: revision });
233
296
  } catch (error) { abort(error); }
234
297
  };
235
298
  };
@@ -264,9 +327,41 @@
264
327
  }
265
328
  await done;
266
329
  }
330
+ const reviewLeaseKey = (key, token) => REVIEW_LEASE_PREFIX + key + ':' + token;
331
+ async function activeReviewLease(key) {
332
+ const prefix = REVIEW_LEASE_PREFIX + key + ':', records = await chromeApi.storage.local.get(null);
333
+ const active = [], expired = [];
334
+ for (const [id, lease] of Object.entries(records)) {
335
+ if (!id.startsWith(prefix)) continue;
336
+ const valid = lease && typeof lease.token === 'string' && lease.token.length > 0 && lease.token.length <= 256
337
+ && id === reviewLeaseKey(key, lease.token) && Number.isSafeInteger(lease.deadlineAt) && lease.deadlineAt > 0;
338
+ if (!valid) throw failure('review_lease_invalid', 'Undo ownership storage could not be verified.');
339
+ if (lease.deadlineAt > Date.now()) active.push(lease); else expired.push(id);
340
+ }
341
+ // Immutable token rows can only be removed by their exact expired key.
342
+ await Promise.all(expired.map(id => chromeApi.storage.local.remove(id).catch(() => {})));
343
+ return active.sort((a, b) => b.deadlineAt - a.deadlineAt)[0];
344
+ }
267
345
  async function handle(message, sender) {
268
346
  const origin = senderOrigin(sender), value = scope(message.scope);
269
347
  const method = message.method;
348
+ if (method === 'review-dispatched') {
349
+ const key = keyFor('review', { ...value, runId: part(message.runId) }), held = locks.get(key);
350
+ if (!held || held.disconnected || held.token !== message.reviewToken || held.tabId !== sender.tab.id)
351
+ throw failure('storage_lock_lost', 'The shared Undo owner was lost before dispatch.');
352
+ const deadlineAt = held.deadlineAt || (held.deadlineAt = Date.now() + 120000);
353
+ held.inFlight += 1;
354
+ try {
355
+ await chromeApi.storage.local.set({ [reviewLeaseKey(key, held.token)]: { token: held.token, deadlineAt } });
356
+ if (held.disconnected) throw failure('storage_lock_lost', 'The shared Undo owner disconnected before dispatch.');
357
+ return { deadlineAt };
358
+ } finally { held.inFlight -= 1; if (held.disconnected && !held.inFlight) held.release(); }
359
+ }
360
+ if (method === 'review-owner') {
361
+ const key = keyFor('review', { ...value, runId: part(message.runId) });
362
+ const lease = await activeReviewLease(key);
363
+ return { busy: locks.has(key) || lease?.deadlineAt > Date.now(), deadlineAt: lease?.deadlineAt || 0 };
364
+ }
270
365
  if (method === 'list') return list(value, typeof message.after === 'string' ? message.after : '');
271
366
  if (method === 'get') {
272
367
  const record = await readOne('sessions', [value.accountScopeId, part(message.id)]);
@@ -284,7 +379,7 @@
284
379
  tx.objectStore('migrations').put({ ...value, origin, complete: true, at: Date.now() });
285
380
  await done; return { complete: true };
286
381
  }
287
- if (method === 'put' || method === 'import') {
382
+ if (method === 'put' || method === 'put-compressed' || method === 'import') {
288
383
  const result = await writeMany(message, value, method === 'import', origin, sender);
289
384
  if (result.length) await notify(value, message.clientId);
290
385
  return result;
@@ -327,12 +422,30 @@
327
422
  queues.delete(key);
328
423
  }
329
424
  port.onMessage.addListener(message => {
425
+ if (message?.type === 'release-review') {
426
+ const held = locks.get(heldKey);
427
+ if (held?.port !== port || held.token !== message.token || !heldKey.startsWith('review:')) return;
428
+ held.inFlight += 1;
429
+ void chromeApi.storage.local.remove(reviewLeaseKey(heldKey, held.token)).then(() => {
430
+ held.inFlight -= 1; held.release(); if (!closed) port.postMessage({ type: 'released' });
431
+ }, () => { held.inFlight -= 1; port.disconnect(); if (held.disconnected && !held.inFlight) held.release(); });
432
+ return;
433
+ }
330
434
  if (message?.type === 'ping') { port.postMessage({ type: 'pong' }); return; }
331
435
  if (message?.type !== 'acquire' || job) return;
332
436
  try {
333
- const value = scope(message.scope), kind = message.kind === 'preferences' ? 'preferences' : 'state';
437
+ const value = scope(message.scope), kind = message.kind === 'review' ? 'review' : message.kind === 'preferences' ? 'preferences' : 'state';
334
438
  heldKey = keyFor(kind, value);
335
- job = { port, tabId: port.sender.tab.id, closed: () => closed, grant: token => port.postMessage({ type: 'granted', token }) };
439
+ if (kind === 'review' && locks.has(heldKey)) { port.postMessage({ type: 'busy' }); return; }
440
+ job = { port, tabId: port.sender.tab.id, closed: () => closed, grant: token => {
441
+ if (kind !== 'review') { port.postMessage({ type: 'granted', token }); return; }
442
+ const held = locks.get(heldKey); held.inFlight += 1;
443
+ void (async () => {
444
+ const existing = await activeReviewLease(heldKey);
445
+ if (existing?.deadlineAt > Date.now()) { locks.get(heldKey)?.release(); if (!closed) port.postMessage({ type: 'busy' }); return; }
446
+ if (!closed) port.postMessage({ type: 'granted', token });
447
+ })().catch(() => port.disconnect()).finally(() => { held.inFlight -= 1; if (held.disconnected && !held.inFlight) held.release(); });
448
+ } };
336
449
  if (!queues.has(heldKey)) queues.set(heldKey, []);
337
450
  queues.get(heldKey).push(job);
338
451
  pump(heldKey);