codex-overleaf-link 2.6.0 → 2.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +9 -9
  2. package/README.zh-CN.md +9 -9
  3. package/extension/bootstrap/manifest.template.json +1 -1
  4. package/extension/src/background.js +70 -0
  5. package/extension/src/content/composerAttachments.js +35 -8
  6. package/extension/src/content/contentRuntime.js +200 -51
  7. package/extension/src/content/diagnosticsController.js +77 -34
  8. package/extension/src/content/diagnosticsExport.js +1 -1
  9. package/extension/src/content/diagnosticsPanel.js +129 -42
  10. package/extension/src/content/generated/content.bundle.js +1283 -331
  11. package/extension/src/content/generated/content.bundle.meta.json +3 -3
  12. package/extension/src/content/localSkillsPanel.js +4 -2
  13. package/extension/src/content/markdownText.js +11 -9
  14. package/extension/src/content/modelPicker.js +28 -19
  15. package/extension/src/content/nativeCompatibilityController.js +23 -9
  16. package/extension/src/content/pageBridgeClient.js +5 -5
  17. package/extension/src/content/panelMaintenance.js +4 -0
  18. package/extension/src/content/panelRenderer.js +4 -1
  19. package/extension/src/content/providerSettingsDialog.js +26 -18
  20. package/extension/src/content/recentProjects.js +26 -16
  21. package/extension/src/content/runQueueScheduler.js +19 -9
  22. package/extension/src/content/runResultActions.js +51 -4
  23. package/extension/src/content/runReviewController.js +9 -3
  24. package/extension/src/content/scopedPersistencePanelState.js +34 -25
  25. package/extension/src/content/selectionContextView.js +30 -3
  26. package/extension/src/content/sharedSessionViewSync.js +9 -1
  27. package/extension/src/content/writebackOrchestrator.js +266 -50
  28. package/extension/src/content/writingStyleSettings.js +70 -17
  29. package/extension/src/page/overleafEditor.js +12 -3
  30. package/extension/src/page/overleafProjectSnapshot.js +56 -3
  31. package/extension/src/page/snapshotRouter.js +75 -66
  32. package/extension/src/page/treeOperations.js +200 -19
  33. package/extension/src/page/writebackRouter.js +152 -40
  34. package/extension/src/pageBridge.js +17 -7
  35. package/extension/src/shared/agentTranscript.js +23 -2
  36. package/extension/src/shared/auditRecords.js +1 -0
  37. package/extension/src/shared/compatibility.js +1 -1
  38. package/extension/src/shared/composerAttachmentDraft.js +22 -7
  39. package/extension/src/shared/failureReasons.js +15 -0
  40. package/extension/src/shared/i18n.js +16 -0
  41. package/extension/src/shared/pageRpcContract.js +1 -1
  42. package/extension/src/shared/projectFiles.js +2 -1
  43. package/extension/src/shared/scopedPersistenceBrowserAdapter.js +34 -0
  44. package/extension/src/shared/staleGuard.js +58 -0
  45. package/extension/src/shared/storageMigration.js +104 -1
  46. package/extension/styles/panel.css +191 -63
  47. package/native-host/src/codexSessionRunner.js +1 -0
  48. package/native-host/src/debugLog.js +27 -3
  49. package/native-host/src/index.js +42 -11
  50. package/native-host/src/localSkills.js +1 -1
  51. package/native-host/src/mirrorWorkspace.js +47 -3
  52. package/native-host/src/nativeEnvironment.js +27 -5
  53. package/native-host/src/providerRuntime.js +1 -0
  54. package/native-host/src/skills/reviewer-response/SKILL.md +65 -0
  55. package/native-host/src/taskRunnerRuntime.js +11 -3
  56. package/native-host/src/updateManager.js +24 -6
  57. package/native-host/src/writingStyleRuntime.js +1 -1
  58. package/package.json +1 -1
@@ -136,6 +136,11 @@
136
136
  fallbackUserMessage: 'The file changed while Codex was working.',
137
137
  fallbackNextAction: 'Review the current file, then rerun the task.'
138
138
  },
139
+ document_identity_mismatch: {
140
+ stage: 'preflight', severity: 'blocked', defaultRetryable: true,
141
+ fallbackUserMessage: 'Codex could not confirm that the editor content belongs to the target file, so it was not used.',
142
+ fallbackNextAction: 'Wait for the target file to open and settle, then retry. Reload Overleaf if this continues.'
143
+ },
139
144
  patch_anchor_not_found: {
140
145
  stage: 'preflight', severity: 'blocked', defaultRetryable: true,
141
146
  fallbackUserMessage: 'The edit anchor no longer matches current Overleaf content.',
@@ -358,6 +363,11 @@
358
363
  fallbackUserMessage: 'Native host request failed.',
359
364
  fallbackNextAction: 'Open diagnostics and retry.'
360
365
  },
366
+ native_request_timeout: {
367
+ stage: 'native', severity: 'error', defaultRetryable: true,
368
+ fallbackUserMessage: 'The local service did not respond.',
369
+ fallbackNextAction: 'Retry, or open Diagnostics to check the local connection.'
370
+ },
361
371
  native_protocol_incompatible: {
362
372
  stage: 'native', severity: 'blocked', defaultRetryable: true,
363
373
  fallbackUserMessage: 'Native host version or capabilities do not match the extension.',
@@ -428,6 +438,11 @@
428
438
  fallbackUserMessage: 'The provider rate limit was reached.',
429
439
  fallbackNextAction: 'Wait for the rate-limit window to recover and retry.'
430
440
  },
441
+ provider_quota_exceeded: {
442
+ stage: 'codex', severity: 'error', defaultRetryable: true,
443
+ fallbackUserMessage: 'The model provider quota or billing limit was reached.',
444
+ fallbackNextAction: 'Check the provider account and plan, or switch model or provider before retrying.'
445
+ },
431
446
  provider_connection_timeout: {
432
447
  stage: 'codex', severity: 'error', defaultRetryable: true,
433
448
  fallbackUserMessage: 'The provider connection timed out.',
@@ -151,6 +151,8 @@
151
151
  failureReason_provider_request_rejected_next: "Review the model ID, reasoning controls, tool support, and request overrides.",
152
152
  failureReason_provider_rate_limited_user: "The provider rate limit was reached.",
153
153
  failureReason_provider_rate_limited_next: "Wait for the rate-limit window to recover and retry.",
154
+ failureReason_provider_quota_exceeded_user: "The model provider quota or billing limit was reached.",
155
+ failureReason_provider_quota_exceeded_next: "Check the provider account and plan, or switch model or provider before retrying.",
154
156
  failureReason_provider_connection_timeout_user: "The provider connection timed out.",
155
157
  failureReason_provider_connection_timeout_next: "Check the endpoint and network, then retry.",
156
158
  failureReason_provider_dns_failed_user: "The provider hostname could not be resolved.",
@@ -639,6 +641,7 @@
639
641
  queuedInputResume: 'Resume',
640
642
  queuedInputRemove: 'Remove',
641
643
  queuedInputAttachmentsUnsupported: 'Queued follow-ups currently support text and @context. Remove binary attachments before queuing.',
644
+ attachmentsLoading: 'Attachments are still loading',
642
645
  queuedInputError_empty_input: 'Enter a follow-up first.',
643
646
  queuedInputError_input_too_long: 'This follow-up is too long to queue.',
644
647
  queuedInputError_queue_full: 'The follow-up queue is full.',
@@ -759,6 +762,8 @@
759
762
  failureReason_accept_replay_failed_next: 'Inspect the file in Overleaf, then retry.',
760
763
  failureReason_native_request_failed_user: 'The request to the local Codex bridge failed.',
761
764
  failureReason_native_request_failed_next: 'Open Diagnostics to check the bridge, then retry.',
765
+ failureReason_native_request_timeout_user: 'The local service did not respond.',
766
+ failureReason_native_request_timeout_next: 'Retry, or open Diagnostics to check the local connection.',
762
767
  failureReason_panel_reload_interrupted_user: 'This run was interrupted by a page refresh.',
763
768
  failureReason_panel_reload_interrupted_next: 'Edit and resend the task to continue.',
764
769
  failureReason_sensitive_content_blocked_user: 'Sensitive content was found before sending, so Codex did not receive this request.',
@@ -785,6 +790,8 @@
785
790
  failureReason_target_editor_not_ready_next: 'Wait for Overleaf to finish loading, then retry.',
786
791
  failureReason_stale_source_changed_user: '{file} changed while Codex was working, so Codex did not overwrite it.',
787
792
  failureReason_stale_source_changed_next: 'Review the current file, then rerun the task.',
793
+ failureReason_document_identity_mismatch_user: 'Codex could not confirm that the editor content belongs to the target file, so it was not used.',
794
+ failureReason_document_identity_mismatch_next: 'Wait for the target file to open and settle, then retry. Reload Overleaf if this continues.',
788
795
  failureReason_patch_anchor_not_found_user: 'The edit anchor for {file} no longer matches current Overleaf content.',
789
796
  failureReason_patch_anchor_not_found_next: 'Rerun the task against the current document.',
790
797
  failureReason_write_observed_mismatch_user: 'Codex attempted to write {file}, but the content read back from Overleaf did not match the approved change.',
@@ -851,6 +858,7 @@
851
858
  recentProjects_previewUnavailableHint: 'Saved conversation could not be loaded.',
852
859
  recentProjects_conversationGone: 'This conversation is no longer available. Choose another conversation from the refreshed list.',
853
860
  recentProjects_continueFailed: 'Could not open the conversation. Please try again.',
861
+ recentProjects_renameFailed: 'Could not rename the conversation. Your title is still available to edit or copy.',
854
862
  recentProjects_welcome_subtitle: 'Conversations saved in this browser',
855
863
  recentProjects_empty: 'No conversations saved in this browser yet. Open an Overleaf project and start a Codex conversation.',
856
864
  recentProjects_sessions_empty: 'No saved conversations in this project',
@@ -1035,6 +1043,8 @@
1035
1043
  failureReason_provider_request_rejected_next: "请检查模型标识、推理设置、工具支持及自定义请求参数。",
1036
1044
  failureReason_provider_rate_limited_user: "模型服务已达到请求频率限制。",
1037
1045
  failureReason_provider_rate_limited_next: "请等待限流窗口恢复后重试。",
1046
+ failureReason_provider_quota_exceeded_user: "模型服务的配额或账单限额已达到。",
1047
+ failureReason_provider_quota_exceeded_next: "请检查模型服务账户和套餐,或切换模型或模型服务后重试。",
1038
1048
  failureReason_provider_connection_timeout_user: "模型服务连接超时。",
1039
1049
  failureReason_provider_connection_timeout_next: "请检查服务端点和网络,然后重试。",
1040
1050
  failureReason_provider_dns_failed_user: "无法解析模型服务的主机名。",
@@ -1523,6 +1533,7 @@
1523
1533
  queuedInputResume: '继续',
1524
1534
  queuedInputRemove: '删除',
1525
1535
  queuedInputAttachmentsUnsupported: '当前排队消息支持文本和 @context;请先移除二进制附件。',
1536
+ attachmentsLoading: '附件读取中',
1526
1537
  queuedInputError_empty_input: '请先输入后续任务。',
1527
1538
  queuedInputError_input_too_long: '这条后续任务太长,无法排队。',
1528
1539
  queuedInputError_queue_full: '后续任务队列已满。',
@@ -1642,6 +1653,8 @@
1642
1653
  failureReason_accept_replay_failed_next: '请在 Overleaf 中检查该文件后重试。',
1643
1654
  failureReason_native_request_failed_user: '向本地 Codex bridge 的请求失败。',
1644
1655
  failureReason_native_request_failed_next: '打开「诊断」检查 bridge 后重试。',
1656
+ failureReason_native_request_timeout_user: '本机服务没有响应。',
1657
+ failureReason_native_request_timeout_next: '请重试,或打开「诊断」检查本机连接。',
1645
1658
  failureReason_panel_reload_interrupted_user: '页面刷新导致本轮中断。',
1646
1659
  failureReason_panel_reload_interrupted_next: '可编辑后重发任务以继续。',
1647
1660
  failureReason_sensitive_content_blocked_user: '发送前发现敏感内容,Codex 没有收到这次请求。',
@@ -1668,6 +1681,8 @@
1668
1681
  failureReason_target_editor_not_ready_next: '请等 Overleaf 加载完成后再重试。',
1669
1682
  failureReason_stale_source_changed_user: '{file} 在任务执行期间被修改过,Codex 没有覆盖它。',
1670
1683
  failureReason_stale_source_changed_next: '请先查看当前文件内容,再重新运行本轮任务。',
1684
+ failureReason_document_identity_mismatch_user: 'Codex 无法确认编辑器内容属于目标文件,因此没有使用这些内容。',
1685
+ failureReason_document_identity_mismatch_next: '请等目标文件打开并稳定后重试;如果问题持续,请重新加载 Overleaf。',
1671
1686
  failureReason_patch_anchor_not_found_user: '{file} 的修改锚点已经无法和当前 Overleaf 内容对齐。',
1672
1687
  failureReason_patch_anchor_not_found_next: '请基于当前文档重新运行任务。',
1673
1688
  failureReason_write_observed_mismatch_user: 'Codex 尝试写入 {file},但写入后从 Overleaf 读回的内容与批准的改动不一致。',
@@ -1734,6 +1749,7 @@
1734
1749
  recentProjects_previewUnavailableHint: '已保存的对话暂时无法读取。',
1735
1750
  recentProjects_conversationGone: '该对话已不可用,请从更新后的列表中选择其他对话。',
1736
1751
  recentProjects_continueFailed: '暂时无法打开对话,请重试。',
1752
+ recentProjects_renameFailed: '暂时无法重命名对话,输入的标题仍保留,可继续编辑或复制。',
1737
1753
  recentProjects_welcome_subtitle: '当前浏览器保存的对话',
1738
1754
  recentProjects_empty: '当前浏览器还没有保存的对话。打开一个 Overleaf 项目,与 Codex 开始对话即可。',
1739
1755
  recentProjects_sessions_empty: '该项目还没有保存的对话',
@@ -5,7 +5,7 @@
5
5
  })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
6
6
  'use strict';
7
7
 
8
- var REVISION = '2026-10-19-mode-menu-switch-v52';
8
+ var REVISION = '2026-10-29-identity-v54';
9
9
  var METHODS = freezeCatalog({
10
10
  initializeCapability: entry('bridge.initialize', 'control', 'none', 'default', false, 'none'),
11
11
  probe: entry('bridge.probe', 'read', 'none', 'default', false, 'safe'),
@@ -67,10 +67,11 @@
67
67
  continue;
68
68
  }
69
69
  seen.add(normalized);
70
- result.push(normalized);
71
70
  if (result.length >= limit) {
71
+ result.truncated = true;
72
72
  break;
73
73
  }
74
+ result.push(normalized);
74
75
  }
75
76
  return result;
76
77
  }
@@ -12,6 +12,7 @@
12
12
  'use strict';
13
13
 
14
14
  var META_PREFIX = 'codex-overleaf-scoped-persistence-v1:';
15
+ var MIRROR_FENCE_PREFIX = 'codex-overleaf-mirror-reuse-fence-v1:';
15
16
  var fallbackMeta = new Map();
16
17
  var cleanPart = QueuePolicy.cleanPart;
17
18
  var normalizeMeta = QueuePolicy.normalizeMeta;
@@ -74,7 +75,40 @@
74
75
  };
75
76
  }
76
77
 
78
+ async function getMirrorReuseFences(scope, chromeApi) {
79
+ chromeApi = chromeApi || (typeof chrome !== 'undefined' ? chrome : null);
80
+ if (!normalizeScope(scope).projectId || !chromeApi?.storage?.local) throw new Error('Mirror fence storage is unavailable.');
81
+ var prefix = MIRROR_FENCE_PREFIX + scopeKey(scope).slice(META_PREFIX.length) + ':';
82
+ var stored = await chromeApi.storage.local.get(null);
83
+ return Object.keys(stored || {}).filter(key => key.startsWith(prefix)).map(key => {
84
+ var generation = key.slice(prefix.length);
85
+ if (!/^[a-zA-Z0-9_-]{1,160}$/.test(generation) || stored[key]?.v !== 1 || stored[key].generation !== generation) {
86
+ throw new Error('Mirror fence storage contains an unreadable generation.');
87
+ }
88
+ return generation;
89
+ }).sort();
90
+ }
91
+
92
+ async function setMirrorReuseFence(scope, generation, chromeApi) {
93
+ chromeApi = chromeApi || (typeof chrome !== 'undefined' ? chrome : null);
94
+ if (!normalizeScope(scope).projectId || !chromeApi?.storage?.local
95
+ || !/^[a-zA-Z0-9_-]{1,160}$/.test(generation)) throw new Error('Mirror fence storage is unavailable.');
96
+ var key = MIRROR_FENCE_PREFIX + scopeKey(scope).slice(META_PREFIX.length) + ':' + generation;
97
+ await chromeApi.storage.local.set({ [key]: { v: 1, generation } });
98
+ }
99
+
100
+ async function clearMirrorReuseFences(scope, generations, chromeApi) {
101
+ chromeApi = chromeApi || (typeof chrome !== 'undefined' ? chrome : null);
102
+ if (!normalizeScope(scope).projectId || !chromeApi?.storage?.local || !Array.isArray(generations)
103
+ || generations.some(generation => !/^[a-zA-Z0-9_-]{1,160}$/.test(generation))) throw new Error('Mirror fence storage is unavailable.');
104
+ var prefix = MIRROR_FENCE_PREFIX + scopeKey(scope).slice(META_PREFIX.length) + ':';
105
+ await chromeApi.storage.local.remove(generations.map(generation => prefix + generation));
106
+ }
107
+
77
108
  return Object.freeze({
109
+ getMirrorReuseFences: getMirrorReuseFences,
110
+ setMirrorReuseFence: setMirrorReuseFence,
111
+ clearMirrorReuseFences: clearMirrorReuseFences,
78
112
  createBrowserAdapter: createBrowserAdapter,
79
113
  normalizeScope: normalizeScope,
80
114
  scopeKey: scopeKey
@@ -7,6 +7,63 @@
7
7
  })(typeof globalThis !== 'undefined' ? globalThis : window, function staleGuardFactory() {
8
8
  'use strict';
9
9
 
10
+ const DOCUMENT_PROVENANCE_OTHER_MIN = 0.9;
11
+ const DOCUMENT_PROVENANCE_CLAIMED_MAX = 0.5;
12
+ const DOCUMENT_PROVENANCE_EDGE_LIMIT = 64 * 1024;
13
+
14
+ function assessDocumentProvenance(input = {}) {
15
+ const { path, projectId, documentIdentity } = input || {};
16
+ if (documentIdentity !== undefined) {
17
+ const valid = documentIdentity && typeof documentIdentity === 'object' && !Array.isArray(documentIdentity)
18
+ && documentIdentity.version === 1 && documentIdentity.source === 'cm-ranges-v1'
19
+ && typeof projectId === 'string' && projectId.length > 0 && documentIdentity.projectId === projectId
20
+ && typeof path === 'string' && path.length > 0 && documentIdentity.path === path
21
+ && typeof documentIdentity.documentId === 'string' && typeof documentIdentity.targetDocumentId === 'string'
22
+ && /^[a-f0-9]{24}$/.test(documentIdentity.documentId)
23
+ && /^[a-f0-9]{24}$/.test(documentIdentity.targetDocumentId)
24
+ && documentIdentity.documentId === documentIdentity.targetDocumentId;
25
+ return valid ? { ok: true, identityState: 'match' }
26
+ : { ok: false, code: 'document_identity_mismatch', identityState: 'mismatch' };
27
+ }
28
+ try {
29
+ const { path, text, references } = input || {};
30
+ if (typeof text !== 'string' || !references) return { ok: true, identityState: 'fallback' };
31
+ const entries = references instanceof Map ? references : Object.entries(references);
32
+ const claimed = references instanceof Map ? references.get(path)
33
+ : Object.prototype.hasOwnProperty.call(references, path) ? references[path] : undefined;
34
+ if (text === claimed) return { ok: true, identityState: 'fallback' };
35
+ let matchedPath, bestScore = -1;
36
+ for (const [otherPath, reference] of entries) {
37
+ if (otherPath === path || typeof reference !== 'string') continue;
38
+ if (text === reference) return { ok: false, code: 'document_identity_mismatch', identityState: 'fallback', matchedPath: otherPath };
39
+ const score = documentSimilarity(text, reference);
40
+ if (score > bestScore) { bestScore = score; matchedPath = otherPath; }
41
+ }
42
+ if (bestScore >= DOCUMENT_PROVENANCE_OTHER_MIN
43
+ && (typeof claimed !== 'string' || documentSimilarity(text, claimed) < DOCUMENT_PROVENANCE_CLAIMED_MAX)) {
44
+ return { ok: false, code: 'document_identity_mismatch', identityState: 'fallback', matchedPath };
45
+ }
46
+ } catch (_error) {
47
+ // Unavailable references supply no contradictory evidence.
48
+ }
49
+ return { ok: true, identityState: 'fallback' };
50
+ }
51
+
52
+ // Shared prefix + non-overlapping suffix / longer length. For large files,
53
+ // sample at most 64 Ki UTF-16 units at each edge and cap the denominator at
54
+ // twice that limit. Exact equality above still compares the complete text.
55
+ // No token arrays or quadratic diff: total work is O(total reference text),
56
+ // with bounded per-reference similarity work and constant auxiliary memory.
57
+ function documentSimilarity(left, right) {
58
+ const length = Math.min(left.length, right.length);
59
+ let prefix = 0, suffix = 0;
60
+ while (prefix < Math.min(length, DOCUMENT_PROVENANCE_EDGE_LIMIT) && left[prefix] === right[prefix]) prefix++;
61
+ while (suffix < Math.min(length - prefix, DOCUMENT_PROVENANCE_EDGE_LIMIT)
62
+ && left[left.length - suffix - 1] === right[right.length - suffix - 1]) suffix++;
63
+ const denominator = Math.min(Math.max(left.length, right.length), 2 * DOCUMENT_PROVENANCE_EDGE_LIMIT);
64
+ return denominator ? (prefix + suffix) / denominator : 1;
65
+ }
66
+
10
67
  function buildBaseFileLookup(files) {
11
68
  if (!Array.isArray(files) || files.length === 0) {
12
69
  return null;
@@ -262,6 +319,7 @@
262
319
  }
263
320
 
264
321
  return {
322
+ assessDocumentProvenance,
265
323
  buildBaseFileLookup,
266
324
  checkOperationFreshness,
267
325
  prepareOperationPatches,
@@ -23,6 +23,101 @@
23
23
  'selectedLocalSkillIdsByProject'
24
24
  ];
25
25
  var projectPreferenceLockTails = {};
26
+ var projectPreferenceBaselines = new Map();
27
+ var projectPreferenceIntents = new Map();
28
+ var projectPreferenceBaselineVersions = new WeakMap();
29
+
30
+ function getProjectPreferenceBaseline(accountScopeId, projectId) {
31
+ var key = buildScopedProjectPreferenceKey(accountScopeId, projectId);
32
+ var baseline = copyPreferenceValue(projectPreferenceBaselines.get(key));
33
+ var intents = projectPreferenceIntents.get(key);
34
+ if (baseline && intents) projectPreferenceBaselineVersions.set(baseline, {
35
+ owner: intents, versions: Object.assign({}, intents.acknowledged)
36
+ });
37
+ return baseline;
38
+ }
39
+
40
+ function adoptProjectPreferenceBaseline(prefs, accountScopeId, projectId) {
41
+ var key = buildScopedProjectPreferenceKey(accountScopeId, projectId);
42
+ if (key) {
43
+ projectPreferenceBaselines.set(key, copyPreferenceValue(prefs));
44
+ projectPreferenceIntents.set(key, { revision: 0, fields: {}, acknowledged: {} });
45
+ }
46
+ }
47
+
48
+ function captureProjectPreferenceWrite(input) {
49
+ var projectId = input.projectId;
50
+ var key = buildScopedProjectPreferenceKey(input.accountScopeId, projectId);
51
+ if (!key) return null;
52
+ var intents = projectPreferenceIntents.get(key);
53
+ if (!intents) {
54
+ intents = { revision: 0, fields: {}, acknowledged: {} };
55
+ projectPreferenceIntents.set(key, intents);
56
+ }
57
+ function values(prefs) {
58
+ return {
59
+ experimentalOtByProject: input.normalizeExperimentalOtByProject(prefs.experimentalOtByProject)[projectId] === true,
60
+ reviewingAvailabilityByProject: prefs.reviewingAvailabilityByProject?.[projectId],
61
+ governanceRulesByProject: input.normalizeGovernanceRulesByProject({
62
+ [projectId]: prefs.governanceRulesByProject?.[projectId] || {}
63
+ })[projectId],
64
+ customInstructionsByProject: input.normalizeCustomInstructionsByProject({
65
+ [projectId]: prefs.customInstructionsByProject?.[projectId] || ''
66
+ })[projectId]
67
+ };
68
+ }
69
+ var baseline = input.baseline || getProjectPreferenceBaseline(input.accountScopeId, projectId);
70
+ var captured = projectPreferenceBaselineVersions.get(baseline);
71
+ var versions = captured?.owner === intents ? captured.versions : {};
72
+ var previous = values(baseline || {});
73
+ var current = values(input.state);
74
+ var token = { key: key, owner: intents, projectId: projectId, fields: {}, versions: {}, written: [] };
75
+ for (var name of Object.keys(current)) {
76
+ var latest = intents.fields[name];
77
+ // A pending or later local intent supersedes the older captured read.
78
+ // Peer adoption alone does not turn an unchanged queued snapshot into an edit.
79
+ var reference = latest && latest.revision > (versions[name] || 0) ? latest.value : previous[name];
80
+ if (JSON.stringify(current[name]) !== JSON.stringify(reference)) {
81
+ latest = { revision: ++intents.revision, value: copyPreferenceValue(current[name]), dirty: true };
82
+ intents.fields[name] = latest;
83
+ }
84
+ token.fields[name] = latest && JSON.stringify(current[name]) === JSON.stringify(latest.value) ? latest : null;
85
+ token.versions[name] = latest?.revision || 0;
86
+ }
87
+ return token;
88
+ }
89
+
90
+ function projectPreferencePatch(token, accountScopeId, projectId) {
91
+ if (token.key !== buildScopedProjectPreferenceKey(accountScopeId, projectId)) {
92
+ throw Object.assign(new Error('Scoped project preferences require account and project identity.'), {
93
+ code: 'account_scope_unavailable'
94
+ });
95
+ }
96
+ var current = projectPreferenceIntents.get(token.key) === token.owner;
97
+ var patch = {};
98
+ token.written = [];
99
+ for (var name of Object.keys(token.fields)) {
100
+ var intent = token.fields[name];
101
+ var changed = current && intent?.dirty && token.owner.fields[name] === intent;
102
+ patch[name] = changed ? { [token.projectId]: copyPreferenceValue(intent.value) } : {};
103
+ if (changed) token.written.push(name);
104
+ }
105
+ return patch;
106
+ }
107
+
108
+ function completeProjectPreferenceWrite(token, prefs) {
109
+ if (projectPreferenceIntents.get(token.key) !== token.owner) return [];
110
+ for (var name of token.written) {
111
+ var intent = token.fields[name];
112
+ intent.dirty = false;
113
+ token.owner.acknowledged[name] = Math.max(token.owner.acknowledged[name] || 0, intent.revision);
114
+ }
115
+ projectPreferenceBaselines.set(token.key, copyPreferenceValue(prefs));
116
+ return Object.keys(token.fields).filter(function (name) {
117
+ var latest = token.owner.fields[name];
118
+ return (latest?.revision || 0) === token.versions[name] && !latest?.dirty;
119
+ });
120
+ }
26
121
 
27
122
  function buildScopedProjectPreferenceKey(accountScopeId, projectId) {
28
123
  var account = compactScopePart(accountScopeId);
@@ -170,6 +265,7 @@
170
265
  var visible = await StorageDb.claimSessionsForAccount(projectId, account, deleted);
171
266
  function result(currentPrefs, sessions, migrated) {
172
267
  var wanted = currentPrefs.activeSessionByProject?.[projectId] || '';
268
+ adoptProjectPreferenceBaseline(currentPrefs, account, projectId);
173
269
  return { prefs: currentPrefs, sessions: sessions,
174
270
  activeSessionId: sessions.some(session => session.id === wanted) ? wanted : sessions.at(-1)?.id || '',
175
271
  migrated: migrated };
@@ -260,7 +356,9 @@
260
356
  return chrome.storage.local.get([PREFS_KEY]).then(function (stored) {
261
357
  var claimed = claimLegacyProjectPreferences(stored[PREFS_KEY] || {}, account, project);
262
358
  var next = mergeScopedProjectPreferences(claimed.prefs, prefs, account, project);
263
- return chrome.storage.local.set({ [PREFS_KEY]: next });
359
+ return chrome.storage.local.set({ [PREFS_KEY]: next }).then(function () {
360
+ return buildProjectPreferenceView(next, account, project);
361
+ });
264
362
  });
265
363
  });
266
364
  }
@@ -464,6 +562,11 @@
464
562
  runMigrationIfNeeded: runMigrationIfNeeded,
465
563
  savePrefs: savePrefs,
466
564
  loadPrefs: loadPrefs,
565
+ getProjectPreferenceBaseline: getProjectPreferenceBaseline,
566
+ adoptProjectPreferenceBaseline: adoptProjectPreferenceBaseline,
567
+ captureProjectPreferenceWrite: captureProjectPreferenceWrite,
568
+ projectPreferencePatch: projectPreferencePatch,
569
+ completeProjectPreferenceWrite: completeProjectPreferenceWrite,
467
570
  loadSessionTombstones: loadSessionTombstones,
468
571
  addSessionTombstones: addSessionTombstones,
469
572
  getDeletedSessionIds: getDeletedSessionIds,