codex-overleaf-link 2.1.1 → 2.1.3

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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <h1>Codex Overleaf Link</h1>
4
4
  <p><strong>Empower Overleaf with Codex.</strong></p>
5
5
  <p>
6
- <img src="https://img.shields.io/badge/version-2.1.1-blue" alt="version">
6
+ <img src="https://img.shields.io/badge/version-2.1.3-blue" alt="version">
7
7
  <img src="https://img.shields.io/badge/platform-macOS%20%2F%20Windows%20%2F%20Linux-lightgrey" alt="platform">
8
8
  <img src="https://img.shields.io/badge/chrome-MV3-green" alt="chrome manifest v3">
9
9
  <img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="node version">
@@ -38,14 +38,14 @@ One command installs the managed native host **and** managed extension runtime.
38
38
  macOS / Linux:
39
39
 
40
40
  ```bash
41
- CODEX_OVERLEAF_REF=v2.1.1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.1/install.sh)"
41
+ CODEX_OVERLEAF_REF=v2.1.3 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.3/install.sh)"
42
42
  ```
43
43
 
44
44
  Windows PowerShell:
45
45
 
46
46
  ```powershell
47
- iwr https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.1/install.ps1 -OutFile install.ps1
48
- $env:CODEX_OVERLEAF_REF='v2.1.1'
47
+ iwr https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.3/install.ps1 -OutFile install.ps1
48
+ $env:CODEX_OVERLEAF_REF='v2.1.3'
49
49
  powershell -ExecutionPolicy Bypass -File install.ps1
50
50
  ```
51
51
 
@@ -56,7 +56,7 @@ Then, in the `chrome://extensions` tab the script opened: enable **Developer mod
56
56
  `npm exec` installs the same managed native host and extension runtime without keeping a source checkout. Use it if you prefer a pinned npm package.
57
57
 
58
58
  ```bash
59
- npm exec --yes codex-overleaf-link@2.1.1 -- install-managed
59
+ npm exec --yes codex-overleaf-link@2.1.3 -- install-managed
60
60
  ```
61
61
 
62
62
  Then, in `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select the managed extension path printed by the command. The Release extension zip remains available for explicitly unmanaged/manual installations.
@@ -86,9 +86,9 @@ npm installs, updates, and uninstalls the coordinated managed extension/native p
86
86
 
87
87
  | Action | Command |
88
88
  |--------|---------|
89
- | Install / update | `npm exec --yes codex-overleaf-link@2.1.1 -- install-managed` |
90
- | Diagnose | `npm exec --yes codex-overleaf-link@2.1.1 -- doctor` |
91
- | Uninstall | `npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed` |
89
+ | Install / update | `npm exec --yes codex-overleaf-link@2.1.3 -- install-managed` |
90
+ | Diagnose | `npm exec --yes codex-overleaf-link@2.1.3 -- doctor` |
91
+ | Uninstall | `npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed` |
92
92
 
93
93
  Use `--extension-id <chrome-extension-id>` only for a custom/dev unpacked extension id that differs from the official bundled id.
94
94
 
@@ -98,13 +98,13 @@ Managed installations discover signed stable updates automatically and apply the
98
98
 
99
99
  ## GitHub Release Artifacts
100
100
 
101
- The v2.1.1 GitHub Release contains:
101
+ The v2.1.3 GitHub Release contains:
102
102
 
103
- - `codex-overleaf-link-extension-v2.1.1.zip`: loadable Chrome extension package for manual unpacked installation.
104
- - `codex-overleaf-native-host-v2.1.1.tar.gz`: native host runtime files used by the installer and release verification.
105
- - `codex-overleaf-link-2.1.1.tgz`: npm native host CLI package for pinned install, doctor, and uninstall flows.
106
- - `install.sh`: release-pinned macOS / Linux installer that defaults to `v2.1.1` when run directly from the release artifact.
107
- - `install.ps1`: release-pinned Windows PowerShell installer that defaults to `v2.1.1` when run directly from the release artifact.
103
+ - `codex-overleaf-link-extension-v2.1.3.zip`: loadable Chrome extension package for manual unpacked installation.
104
+ - `codex-overleaf-native-host-v2.1.3.tar.gz`: native host runtime files used by the installer and release verification.
105
+ - `codex-overleaf-link-2.1.3.tgz`: npm native host CLI package for pinned install, doctor, and uninstall flows.
106
+ - `install.sh`: release-pinned macOS / Linux installer that defaults to `v2.1.3` when run directly from the release artifact.
107
+ - `install.ps1`: release-pinned Windows PowerShell installer that defaults to `v2.1.3` when run directly from the release artifact.
108
108
  - `uninstall-native-host.mjs`: native host uninstaller that removes the Chrome Native Messaging manifest, bridge executable, and runtime copy.
109
109
  - `nativeHostPlatform.js`, `manifest.js`, `runtimeInstaller.js`: helper files required by the loose uninstaller asset.
110
110
  - `SHA256SUMS` and `release-manifest.json`: checksum and artifact metadata for release verification.
@@ -115,7 +115,7 @@ The v2.1.1 GitHub Release contains:
115
115
  Remove the native host (use `--browser chromium` on Linux Chromium):
116
116
 
117
117
  ```bash
118
- npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed
118
+ npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed
119
119
  ```
120
120
 
121
121
  The same command works on Windows PowerShell. If you installed from a manual checkout or source installer, you can also run `npm run uninstall:native` inside the repo, use `node ~/.codex-overleaf/source/scripts/uninstall-native-host.mjs` on macOS / Linux, or use `node $env:LOCALAPPDATA\CodexOverleaf\source\scripts\uninstall-native-host.mjs` on Windows PowerShell.
@@ -150,13 +150,13 @@ Then remove the extension from `chrome://extensions`. To delete local data: on m
150
150
  Linux Chromium install or update:
151
151
 
152
152
  ```bash
153
- CODEX_OVERLEAF_REF=v2.1.1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.1/install.sh)" -- --browser chromium
153
+ CODEX_OVERLEAF_REF=v2.1.3 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Ghqqqq/codex-overleaf-link/v2.1.3/install.sh)" -- --browser chromium
154
154
  ```
155
155
 
156
156
  Linux Chromium uninstall:
157
157
 
158
158
  ```bash
159
- npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed --browser chromium
159
+ npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed --browser chromium
160
160
  ```
161
161
 
162
162
  ## Features
@@ -292,7 +292,7 @@ Composer attachments are turn-scoped Codex context. Limits are 8 attachments per
292
292
  Re-run any [native host installer](#install), reload the extension in `chrome://extensions`, then refresh the Overleaf tab. This also fixes extension/native version mismatch and native protocol mismatch.
293
293
 
294
294
  ```bash
295
- npm exec --yes codex-overleaf-link@2.1.1 -- install-managed
295
+ npm exec --yes codex-overleaf-link@2.1.3 -- install-managed
296
296
  ```
297
297
 
298
298
  **The Windows popup or panel shows a Bash recovery command**
@@ -341,8 +341,8 @@ Use this matrix for release-candidate signoff and compatibility reports. Record
341
341
  | Browser/channel/version | Google Chrome channel and version. | Google Chrome channel and version. | Google Chrome channel and version. | Chromium channel/package and version. |
342
342
  | Install mode | Manual unpacked extension from GitHub Release zip or checkout. | Manual unpacked extension from GitHub Release zip or checkout. | Manual unpacked extension from GitHub Release zip or checkout. | Manual unpacked extension from GitHub Release zip or checkout; native host installed with `--browser chromium`. |
343
343
  | Extension id | Bundled id `illdpneeeopfffmiepaejglgmhpmdhdc`, or actual custom id passed with `--extension-id`. | Bundled id `illdpneeeopfffmiepaejglgmhpmdhdc`, or actual custom id passed with `--extension-id`. | Bundled id `illdpneeeopfffmiepaejglgmhpmdhdc`, or actual custom id passed with `--extension-id`. | Bundled id `illdpneeeopfffmiepaejglgmhpmdhdc`, or actual custom id passed with `--extension-id`. |
344
- | Installer/update command | `npm exec --yes codex-overleaf-link@2.1.1 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- install-managed --browser chromium` |
345
- | Uninstall command | `npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-managed --browser chromium` |
344
+ | Installer/update command | `npm exec --yes codex-overleaf-link@2.1.3 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- install-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- install-managed --browser chromium` |
345
+ | Uninstall command | `npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed` | `npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-managed --browser chromium` |
346
346
  | Manifest/registry path | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.codex.overleaf.json` | `HKCU\Software\Google\Chrome\NativeMessagingHosts\com.codex.overleaf` -> `%LOCALAPPDATA%\CodexOverleaf\native-host-runtime\com.codex.overleaf.json` | `~/.config/google-chrome/NativeMessagingHosts/com.codex.overleaf.json` | `~/.config/chromium/NativeMessagingHosts/com.codex.overleaf.json` |
347
347
  | Bridge/runtime/source path | Bridge `~/.codex-overleaf/codex-overleaf-bridge`; runtime `~/.codex-overleaf/native-host-runtime`; source `~/.codex-overleaf/source`. | Bridge `%LOCALAPPDATA%\CodexOverleaf\codex-overleaf-bridge.cmd`; runtime `%LOCALAPPDATA%\CodexOverleaf\native-host-runtime`; source `%LOCALAPPDATA%\CodexOverleaf\source`. | Bridge `~/.codex-overleaf/codex-overleaf-bridge`; runtime `~/.codex-overleaf/native-host-runtime`; source `~/.codex-overleaf/source`. | Bridge `~/.codex-overleaf/codex-overleaf-bridge`; runtime `~/.codex-overleaf/native-host-runtime`; source `~/.codex-overleaf/source`. |
348
348
  | Node/Git/Codex/TeX | Node.js >= 20; Git; Codex CLI installed and logged in; TeX optional. | Node.js >= 20; Git; Codex CLI installed and logged in; TeX optional. | Node.js >= 20; Git; Codex CLI installed and logged in; TeX optional. | Node.js >= 20; Git; Codex CLI installed and logged in; TeX optional. |
@@ -378,7 +378,7 @@ Contributions are welcome. Please open an issue before submitting large changes
378
378
  The recommended v1.9 installation owns one stable unpacked-extension directory and a versioned native-host directory:
379
379
 
380
380
  ```bash
381
- npm exec --yes codex-overleaf-link@2.1.1 -- install-managed
381
+ npm exec --yes codex-overleaf-link@2.1.3 -- install-managed
382
382
  ```
383
383
 
384
384
  Load the printed extension path once from `chrome://extensions`. Starting with the next signed stable release, Codex Overleaf Link checks GitHub Releases automatically, downloads and verifies the coordinated extension/native-host bundle, waits until every connected Overleaf tab is saved and idle, and then updates both components together. A failed health check restores the previous version automatically.
@@ -388,11 +388,11 @@ The stable updater ignores drafts and prereleases, performs no telemetry, and ca
388
388
  The legacy native-only installer remains available for source or unmanaged extension directories:
389
389
 
390
390
  ```bash
391
- npm exec --yes codex-overleaf-link@2.1.1 -- install-native
391
+ npm exec --yes codex-overleaf-link@2.1.3 -- install-native
392
392
  ```
393
393
 
394
394
  Legacy native-only installations can be removed with:
395
395
 
396
396
  ```bash
397
- npm exec --yes codex-overleaf-link@2.1.1 -- uninstall-native
397
+ npm exec --yes codex-overleaf-link@2.1.3 -- uninstall-native
398
398
  ```
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Codex Overleaf Link",
4
- "version": "2.1.1",
4
+ "version": "2.1.3",
5
5
  "description": "Connect the active Overleaf project to a local Codex bridge.",
6
6
  "icons": {
7
7
  "16": "assets/icons/icon16.png",
@@ -1,6 +1,5 @@
1
1
  (function initCodexOverleafContentRuntime(root) {
2
2
  'use strict';
3
-
4
3
  function init() {
5
4
  'use strict';
6
5
 
@@ -438,7 +437,7 @@
438
437
  saveState,
439
438
  saveStateSoon,
440
439
  readPanelInputs,
441
- applyStateToPanel,
440
+ applyStateToPanel, syncSessionProvider: () => providerSettingsCoordinator.syncSessionProvider(),
442
441
  getPanel: () => panel,
443
442
  getState: () => state,
444
443
  setState: next => { state = next; },
@@ -569,7 +568,8 @@
569
568
  tr,
570
569
  tx,
571
570
  getLocale,
572
- sendBackgroundNative,
571
+ sendBackgroundNative, getProviderSelection: () => providerSettingsCoordinator.getRunSelection(state?.providerId)
572
+ || { providerId: state?.providerId || 'builtin', providerRevision: 0 },
573
573
  readSelectedSpeedInput,
574
574
  getRenderedModelEntries,
575
575
  persistPanelInputs,
@@ -603,8 +603,8 @@
603
603
  document,
604
604
  window,
605
605
  getSettingsPanelInstance: () => settingsPanelInstance,
606
- getSelectedModel: () => state?.model || '',
607
- clearSelectedModel: () => { if (state) state.model = ''; },
606
+ getSelectedModel: () => state?.model || '', getSelectedProviderId: () => state?.providerId || 'builtin',
607
+ setSelectedProviderId: providerId => { state = updateActiveSession(state, { providerId: providerId || 'builtin' }); },
608
608
  refreshModelOptions: loadModelOptions, persistInputs: persistPanelInputs
609
609
  });
610
610
  // v1.8.1 D3: the dashboard was a one-shot render — another tab finishing a
@@ -898,9 +898,7 @@
898
898
  }
899
899
  })
900
900
  .catch(() => { /* fail-closed inside refreshAccountScopeId */ });
901
- loadModelOptions().catch(error => {
902
- applyFallbackModelOptions(resolveSelectedModel(), error);
903
- });
901
+ providerSettingsCoordinator.syncSessionProvider().catch(() => {});
904
902
  syncOtWarmMirrorController().catch(error => {
905
903
  updateOtStatusDisplay('unavailable');
906
904
  appendPlainLog(tx(`Failed to sync experimental OT warm mirror: ${error.message}`, `同步实验性 OT 预热镜像失败:${error.message}`));
@@ -2935,7 +2933,7 @@
2935
2933
  });
2936
2934
  return {
2937
2935
  ...params,
2938
- providerSelection: providerSettingsCoordinator.getRunSelection()
2936
+ providerSelection: providerSettingsCoordinator.getRunSelection(state?.providerId)
2939
2937
  };
2940
2938
  }
2941
2939
 
@@ -2949,6 +2947,7 @@
2949
2947
  sessionId: input.sessionId || '',
2950
2948
  turnId: currentRunView?.recordId || '',
2951
2949
  mode: input.mode || state?.mode || '',
2950
+ ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
2952
2951
  model: state?.model || '',
2953
2952
  reasoningEffort: state?.reasoningEffort || '',
2954
2953
  speedTier: state?.speedTier || '',
@@ -5967,7 +5966,7 @@
5967
5966
  const { prefs, sessions, activeSessionId } = await Migration.runMigrationIfNeeded(projectId, legacyKey);
5968
5967
 
5969
5968
  return {
5970
- model: prefs.model || '',
5969
+ model: prefs.model || '', providerId: prefs.providerId || 'builtin',
5971
5970
  reasoningEffort: prefs.reasoningEffort || '',
5972
5971
  speedTier: prefs.speedTier || '',
5973
5972
  mode: prefs.mode || '',
@@ -5992,7 +5991,7 @@
5992
5991
  runs: Array.isArray(session.runs) ? session.runs : [],
5993
5992
  history: Array.isArray(session.history) ? session.history : [],
5994
5993
  task: typeof session.task === 'string' ? session.task : '',
5995
- mode: session.mode || prefs.mode || 'confirm',
5994
+ mode: session.mode || prefs.mode || 'confirm', providerId: session.providerId || 'builtin',
5996
5995
  model: session.model || prefs.model || 'gpt-5.4',
5997
5996
  reasoningEffort: session.reasoningEffort || prefs.reasoningEffort || 'high',
5998
5997
  speedTier: session.speedTier || prefs.speedTier || 'standard',
@@ -6012,7 +6011,7 @@
6012
6011
  const fallback = stored[storageKey] || stored[LEGACY_STORAGE_KEY] || {};
6013
6012
  if (fallback?.__codexOverleafCompactFallback === true) {
6014
6013
  return {
6015
- mode: fallback.mode || '',
6014
+ mode: fallback.mode || '', providerId: fallback.providerId || 'builtin',
6016
6015
  model: fallback.model || '',
6017
6016
  reasoningEffort: fallback.reasoningEffort || '',
6018
6017
  speedTier: fallback.speedTier || '',
@@ -6453,7 +6452,7 @@
6453
6452
 
6454
6453
  function applyStateToPanel() {
6455
6454
  applyPanelTheme(getThemePreference());
6456
- if (!modelSelectHasOption(state.model)) {
6455
+ if ((state.providerId || 'builtin') === 'builtin' && !modelSelectHasOption(state.model)) {
6457
6456
  renderModelOptions(getModelCatalog().FALLBACK_MODELS, state.model);
6458
6457
  }
6459
6458
  panel.querySelector('[data-model]').value = state.model;
@@ -6545,6 +6544,7 @@
6545
6544
  id: createRunId(),
6546
6545
  task,
6547
6546
  mode,
6547
+ ...providerSettingsCoordinator.getProviderSnapshot(state?.providerId),
6548
6548
  model,
6549
6549
  reasoningEffort,
6550
6550
  speedTier,
@@ -13,6 +13,7 @@
13
13
  tx,
14
14
  getLocale,
15
15
  sendBackgroundNative,
16
+ getProviderSelection,
16
17
  readSelectedSpeedInput,
17
18
  getRenderedModelEntries,
18
19
  persistPanelInputs,
@@ -33,6 +34,7 @@
33
34
  } = Support;
34
35
 
35
36
  let modelDiscovery = { status: 'fallback', source: 'fallback', fetchedAt: '' };
37
+ let modelLoadGeneration = 0;
36
38
 
37
39
  function getModelDiscovery() {
38
40
  return modelDiscovery;
@@ -96,7 +98,9 @@
96
98
  updateModelDisplay();
97
99
  await persistPanelInputs();
98
100
  }
99
- async function loadModelOptions() {
101
+ async function loadModelOptions(providerSelection) {
102
+ const generation = ++modelLoadGeneration;
103
+ const selection = providerSelection || getProviderSelection?.() || null;
100
104
  const selectedModel = resolveSelectedModel();
101
105
  const modelCatalog = getModelCatalog();
102
106
  const fallbackModels = modelCatalog.FALLBACK_MODELS;
@@ -104,8 +108,19 @@
104
108
  try {
105
109
  const response = await sendBackgroundNative({
106
110
  method: 'codex.models',
107
- params: {}
111
+ params: selection ? { providerSelection: selection } : {}
108
112
  });
113
+ if (generation !== modelLoadGeneration) {
114
+ return { stale: true };
115
+ }
116
+ const responseProviderId = response?.result?.providerId || '';
117
+ if (selection?.providerId && responseProviderId && responseProviderId !== selection.providerId) {
118
+ return { stale: true };
119
+ }
120
+ if (selection?.providerId && selection.providerId !== 'builtin'
121
+ && Number(response?.result?.providerRevision) !== Number(selection.providerRevision)) {
122
+ return { stale: true };
123
+ }
109
124
  const currentSelectedModel = resolveSelectedModel() || selectedModel;
110
125
  const hasDiscoveredModels = response?.ok
111
126
  && Array.isArray(response.result?.models)
@@ -121,17 +136,25 @@
121
136
  applyUnavailableModelOptions({ code: 'provider_model_catalog_invalid', message: 'The active provider returned no usable models.' });
122
137
  return;
123
138
  }
124
- renderModelOptions(normalized.models, retainedSelectedModel);
139
+ renderModelOptions(normalized.models, retainedSelectedModel, { allowUnknownSelected: false });
125
140
  modelDiscovery = {
126
141
  status: hasDiscoveredModels && !normalized.usedFallback ? 'discovered' : 'fallback',
127
142
  source: hasDiscoveredModels ? response.result?.source || 'unknown' : 'fallback',
128
143
  fetchedAt: hasDiscoveredModels ? response.result?.fetchedAt || '' : '',
129
144
  errorCode: hasDiscoveredModels ? '' : response?.error?.code || '',
130
- errorMessage: hasDiscoveredModels ? '' : response?.error?.message || ''
145
+ errorMessage: hasDiscoveredModels ? '' : response?.error?.message || '',
146
+ providerId: responseProviderId || selection?.providerId || 'builtin',
147
+ providerRevision: Number(response?.result?.providerRevision || selection?.providerRevision || 0)
131
148
  };
132
149
  updateModelDisplay();
150
+ await persistPanelInputs();
151
+ return { stale: false, providerId: modelDiscovery.providerId };
133
152
  } catch (error) {
153
+ if (generation !== modelLoadGeneration) {
154
+ return { stale: true };
155
+ }
134
156
  applyUnavailableModelOptions(error);
157
+ return { stale: false, error };
135
158
  }
136
159
  }
137
160
 
@@ -140,14 +163,13 @@
140
163
  if (modelSelect) {
141
164
  modelSelect.textContent = '';
142
165
  const option = document.createElement('option');
143
- option.value = '';
166
+ option.value = resolveSelectedModel();
144
167
  option.textContent = tx('Models unavailable', '模型不可用');
145
168
  option.disabled = true;
146
169
  modelSelect.append(option);
147
170
  modelSelect.value = '';
148
171
  modelSelect.disabled = true;
149
172
  }
150
- if (getState()) getState().model = '';
151
173
  modelDiscovery = {
152
174
  status: 'unavailable',
153
175
  source: 'custom-provider',
@@ -182,7 +204,7 @@
182
204
  return Support.getModelCatalog({ getPanel });
183
205
  }
184
206
 
185
- function renderModelOptions(models, selectedModel) {
207
+ function renderModelOptions(models, selectedModel, options = {}) {
186
208
  const modelSelect = getPanel()?.querySelector('[data-model]');
187
209
  if (!modelSelect) {
188
210
  return;
@@ -219,7 +241,7 @@
219
241
  }
220
242
  }
221
243
 
222
- if (selectedId && !renderedSelected) {
244
+ if (selectedId && !renderedSelected && options.allowUnknownSelected !== false) {
223
245
  const option = document.createElement('option');
224
246
  option.value = selectedId;
225
247
  option.textContent = `${selectedId} (custom)`;
@@ -8,6 +8,8 @@
8
8
  sendBackgroundNative: options.sendBackgroundNative,
9
9
  getSettingsPanelInstance: options.getSettingsPanelInstance || (() => null),
10
10
  getSelectedModel: options.getSelectedModel || (() => ''),
11
+ getSelectedProviderId: options.getSelectedProviderId || (() => 'builtin'),
12
+ setSelectedProviderId: options.setSelectedProviderId || (() => {}),
11
13
  clearSelectedModel: options.clearSelectedModel || (() => {}),
12
14
  refreshModelOptions: options.refreshModelOptions || (() => {}),
13
15
  persistInputs: options.persistInputs || (() => {}),
@@ -38,7 +40,12 @@
38
40
  open: () => open(instance),
39
41
  refreshSummary: () => refresh(instance),
40
42
  ensureLoaded: () => ensureLoaded(instance),
41
- getRunSelection: () => instance.loaded ? Profiles.buildRunSelection(instance.catalog) : null,
43
+ getRunSelection: providerId => instance.loaded
44
+ ? Profiles.buildRunSelection(instance.catalog, providerId || instance.getSelectedProviderId())
45
+ : null,
46
+ getProviderSnapshot: providerId => getProviderSnapshot(instance, providerId),
47
+ isProviderAvailable: providerId => Boolean(Profiles.getProviderById(instance.catalog, providerId)),
48
+ syncSessionProvider: () => syncSessionProvider(instance),
42
49
  getCatalog: () => instance.catalog,
43
50
  destroy: () => destroy(instance),
44
51
  _instance: instance
@@ -86,19 +93,33 @@
86
93
  return instance.catalog;
87
94
  }
88
95
 
96
+ async function syncSessionProvider(instance) {
97
+ try {
98
+ await ensureLoaded(instance);
99
+ } catch (_error) {
100
+ return instance.refreshModelOptions(
101
+ window.CodexOverleafProviderProfiles.buildRunSelection(instance.catalog, instance.getSelectedProviderId())
102
+ );
103
+ }
104
+ return reconcileProviderSelection(instance, instance.catalog, { forceSessionRefresh: true });
105
+ }
106
+
89
107
  async function reconcileProviderSelection(instance, catalog, change = {}) {
90
- if (!change.activeProviderChanged && !change.activeModelCatalogChanged) {
91
- return;
108
+ const previousProviderId = instance.getSelectedProviderId() || 'builtin';
109
+ const requestedProviderId = typeof change.sessionProviderId === 'string' && change.sessionProviderId
110
+ ? change.sessionProviderId
111
+ : previousProviderId;
112
+ const sessionProviderChanged = requestedProviderId !== previousProviderId;
113
+ if (sessionProviderChanged) {
114
+ instance.setSelectedProviderId(requestedProviderId);
92
115
  }
93
- const previousModel = instance.getSelectedModel();
94
- const activeProvider = window.CodexOverleafProviderProfiles.getActiveProvider(catalog);
95
- const previousModelUnavailable = activeProvider.kind === 'custom'
96
- && previousModel
97
- && !activeProvider.models.some(model => model.id === previousModel);
98
- if (change.activeProviderChanged || previousModelUnavailable) {
99
- instance.clearSelectedModel();
116
+ const changedProviderIds = Array.isArray(change.changedProviderIds) ? change.changedProviderIds : [];
117
+ if (!change.forceSessionRefresh && !sessionProviderChanged && !changedProviderIds.includes(requestedProviderId)) {
118
+ return;
100
119
  }
101
- await instance.refreshModelOptions();
120
+ await instance.refreshModelOptions(
121
+ window.CodexOverleafProviderProfiles.buildRunSelection(catalog, requestedProviderId)
122
+ );
102
123
  await instance.persistInputs();
103
124
  }
104
125
 
@@ -174,7 +195,9 @@
174
195
  updateDialog: true,
175
196
  selectedId: result.savedProviderId
176
197
  });
177
- await notifyChanged(instance, applied.change);
198
+ await notifyChanged(instance, applied.change, action.activate === true
199
+ ? { sessionProviderId: result.savedProviderId }
200
+ : {});
178
201
  instance.dialog.setBusy('', '');
179
202
  const fellBackToBuiltin = previousCatalog.activeProviderId === result.savedProviderId
180
203
  && result.activeProviderId === 'builtin'
@@ -249,7 +272,7 @@
249
272
  updateDialog: true,
250
273
  selectedId: context.profileId
251
274
  });
252
- await notifyChanged(instance, applied.change);
275
+ await notifyChanged(instance, applied.change, { sessionProviderId: context.profileId });
253
276
  instance.dialog.setBusy('', '');
254
277
  instance.dialog.setStatus({
255
278
  tone: 'success',
@@ -269,7 +292,7 @@
269
292
  expectedRevision: 0
270
293
  });
271
294
  const applied = applyCatalog(instance, result, { updateDialog: true, selectedId: 'builtin' });
272
- await notifyChanged(instance, applied.change);
295
+ await notifyChanged(instance, applied.change, { sessionProviderId: 'builtin' });
273
296
  instance.dialog.setBusy('', '');
274
297
  instance.dialog.setStatus({ tone: 'success', title: instance.tx('Built-in Codex is active.', '已启用内置 Codex。') });
275
298
  } catch (error) {
@@ -315,22 +338,22 @@
315
338
  function updateSettingsSummary(instance, override = {}) {
316
339
  const active = window.CodexOverleafProviderProfiles.getActiveProvider(instance.catalog);
317
340
  const summary = override.summary || (active.kind === 'builtin'
318
- ? instance.tx('Built-in Codex is active', '当前使用内置 Codex')
319
- : `${active.name} · ${active.defaultModelId || instance.tx('No model', '未配置模型')} · ${instance.tx('Active', '使用中')}`);
341
+ ? instance.tx('Built-in Codex · default for new sessions', '内置 Codex · 新会话默认')
342
+ : `${active.name} · ${active.defaultModelId || instance.tx('No model', '未配置模型')} · ${instance.tx('Default for new sessions', '新会话默认')}`);
320
343
  instance.getSettingsPanelInstance()?.setProviderSummary?.({
321
344
  summary,
322
345
  tone: override.tone || (active.kind === 'custom' && active.wireApiPreference === 'auto' && !active.resolvedWireApi ? 'warning' : 'ok')
323
346
  });
324
347
  }
325
348
 
326
- async function notifyChanged(instance, change = emptyCatalogChange()) {
349
+ async function notifyChanged(instance, change = emptyCatalogChange(), localChange = {}) {
327
350
  try {
328
351
  instance.channel?.postMessage?.({ type: 'provider-settings-changed', change, at: Date.now() });
329
352
  } catch (_error) {
330
353
  // Revision checks remain authoritative when BroadcastChannel is unavailable.
331
354
  }
332
355
  try {
333
- await instance.onProviderChanged(instance.catalog, change);
356
+ await instance.onProviderChanged(instance.catalog, { ...change, ...localChange });
334
357
  } catch (_error) {
335
358
  // The authoritative catalog is already saved. A later refresh can retry
336
359
  // the local model projection without rolling back the provider change.
@@ -356,9 +379,19 @@
356
379
  function describeCatalogChange(previous, next) {
357
380
  const previousActive = window.CodexOverleafProviderProfiles.getActiveProvider(previous);
358
381
  const nextActive = window.CodexOverleafProviderProfiles.getActiveProvider(next);
382
+ const providerIds = new Set([
383
+ ...(previous.providers || []).map(provider => provider.id),
384
+ ...(next.providers || []).map(provider => provider.id)
385
+ ]);
386
+ const changedProviderIds = Array.from(providerIds).filter(providerId => {
387
+ const previousProvider = window.CodexOverleafProviderProfiles.getProviderById(previous, providerId);
388
+ const nextProvider = window.CodexOverleafProviderProfiles.getProviderById(next, providerId);
389
+ return modelCatalogSignature(previousProvider) !== modelCatalogSignature(nextProvider);
390
+ });
359
391
  return {
360
392
  activeProviderChanged: previousActive.id !== nextActive.id,
361
- activeModelCatalogChanged: modelCatalogSignature(previousActive) !== modelCatalogSignature(nextActive)
393
+ activeModelCatalogChanged: modelCatalogSignature(previousActive) !== modelCatalogSignature(nextActive),
394
+ changedProviderIds
362
395
  };
363
396
  }
364
397
 
@@ -377,7 +410,32 @@
377
410
  }
378
411
 
379
412
  function emptyCatalogChange() {
380
- return { activeProviderChanged: false, activeModelCatalogChanged: false };
413
+ return { activeProviderChanged: false, activeModelCatalogChanged: false, changedProviderIds: [] };
414
+ }
415
+
416
+ function getProviderSnapshot(instance, providerId) {
417
+ const requestedId = providerId || instance.getSelectedProviderId() || 'builtin';
418
+ const provider = window.CodexOverleafProviderProfiles.getProviderById(instance.catalog, requestedId);
419
+ if (!provider) {
420
+ return {
421
+ providerId: requestedId,
422
+ providerName: 'Unavailable provider',
423
+ providerRevision: 0,
424
+ providerEndpointHost: ''
425
+ };
426
+ }
427
+ let endpointHost = '';
428
+ try {
429
+ endpointHost = provider.baseUrl ? new URL(provider.baseUrl).host : '';
430
+ } catch (_error) {
431
+ endpointHost = '';
432
+ }
433
+ return {
434
+ providerId: provider.id,
435
+ providerName: provider.name || provider.id,
436
+ providerRevision: provider.revision || 0,
437
+ providerEndpointHost: endpointHost
438
+ };
381
439
  }
382
440
 
383
441
  function formatError(instance, error) {
@@ -28,6 +28,7 @@
28
28
  saveStateSoon,
29
29
  readPanelInputs,
30
30
  applyStateToPanel,
31
+ syncSessionProvider,
31
32
  getPanel,
32
33
  getState,
33
34
  setState,
@@ -98,6 +99,7 @@
98
99
  }
99
100
  const session = createSession({
100
101
  mode: getState().mode,
102
+ providerId: getState().providerId || 'builtin',
101
103
  model: getState().model,
102
104
  reasoningEffort: getState().reasoningEffort,
103
105
  speedTier: getState().speedTier,
@@ -108,8 +110,9 @@
108
110
  sessions: [...(getState().sessions || []), session].slice(-20),
109
111
  activeSessionId: session.id
110
112
  }));
111
- await saveState();
112
113
  applyStateToPanel();
114
+ await syncSessionProvider?.();
115
+ await saveState();
113
116
  getPanel().querySelector('[data-task]')?.focus();
114
117
  }
115
118
 
@@ -130,8 +133,9 @@
130
133
  }
131
134
  readPanelInputs();
132
135
  setState(setActiveSession(getState(), sessionId));
133
- await saveState();
134
136
  applyStateToPanel();
137
+ await syncSessionProvider?.();
138
+ await saveState();
135
139
  }
136
140
 
137
141
  async function deleteSessionWithConfirm(sessionId) {
@@ -31,6 +31,10 @@
31
31
  createdAt: redactSecretLikeText(stringField(input.createdAt)) || now,
32
32
  completedAt: '',
33
33
  mode: redactSecretLikeText(stringField(input.mode)),
34
+ providerId: redactSecretLikeText(stringField(input.providerId)) || 'builtin',
35
+ providerName: redactSecretLikeText(stringField(input.providerName)),
36
+ providerRevision: finiteNumber(input.providerRevision) || 0,
37
+ providerEndpointHost: redactSecretLikeText(stringField(input.providerEndpointHost)),
34
38
  model: redactSecretLikeText(stringField(input.model)),
35
39
  reasoningEffort: redactSecretLikeText(stringField(input.reasoningEffort)),
36
40
  speedTier: redactSecretLikeText(stringField(input.speedTier)),
@@ -85,6 +89,10 @@
85
89
  createdAt: redactSecretLikeText(stringField(input.createdAt)) || new Date().toISOString(),
86
90
  completedAt: redactSecretLikeText(stringField(input.completedAt)),
87
91
  mode: redactSecretLikeText(stringField(input.mode)),
92
+ providerId: redactSecretLikeText(stringField(input.providerId)) || 'builtin',
93
+ providerName: redactSecretLikeText(stringField(input.providerName)),
94
+ providerRevision: finiteNumber(input.providerRevision) || 0,
95
+ providerEndpointHost: redactSecretLikeText(stringField(input.providerEndpointHost)),
88
96
  model: redactSecretLikeText(stringField(input.model)),
89
97
  reasoningEffort: redactSecretLikeText(stringField(input.reasoningEffort)),
90
98
  speedTier: redactSecretLikeText(stringField(input.speedTier)),
@@ -12,7 +12,7 @@
12
12
  const MIN_NATIVE_VERSION = '1.0.0';
13
13
  const MIN_COMPATIBLE_NATIVE_VERSION = '1.0.0';
14
14
  const MIN_COMPATIBLE_EXTENSION_VERSION = '1.0.0';
15
- const BUILD_TARGET_VERSION = '2.1.1';
15
+ const BUILD_TARGET_VERSION = '2.1.3';
16
16
  const DEFAULT_CHROME_EXTENSION_ID = 'illdpneeeopfffmiepaejglgmhpmdhdc';
17
17
  const REQUIRED_CAPABILITIES = Object.freeze([
18
18
  'bridgePing',
@@ -175,11 +175,23 @@
175
175
  || buildBuiltinProvider();
176
176
  }
177
177
 
178
- function buildRunSelection(catalog = {}) {
179
- const active = getActiveProvider(catalog);
178
+ function getProviderById(catalog = {}, providerId = '') {
179
+ const normalized = normalizeCatalog(catalog);
180
+ const requestedId = normalizeText(providerId) || normalized.activeProviderId || BUILTIN_PROVIDER_ID;
181
+ if (requestedId === BUILTIN_PROVIDER_ID) {
182
+ return normalized.providers.find(provider => provider.id === BUILTIN_PROVIDER_ID)
183
+ || buildBuiltinProvider();
184
+ }
185
+ return normalized.providers.find(provider => provider.id === requestedId) || null;
186
+ }
187
+
188
+ function buildRunSelection(catalog = {}, providerId = '') {
189
+ const normalized = normalizeCatalog(catalog);
190
+ const requestedId = normalizeText(providerId) || normalized.activeProviderId || BUILTIN_PROVIDER_ID;
191
+ const provider = getProviderById(normalized, requestedId);
180
192
  return {
181
- providerId: active.id,
182
- providerRevision: active.revision || 0
193
+ providerId: requestedId,
194
+ providerRevision: provider?.revision || 0
183
195
  };
184
196
  }
185
197
 
@@ -277,6 +289,7 @@
277
289
  buildEmptyDraft,
278
290
  buildRunSelection,
279
291
  getActiveProvider,
292
+ getProviderById,
280
293
  normalizeCatalog,
281
294
  normalizeProvider
282
295
  };
@@ -13,6 +13,7 @@
13
13
 
14
14
  const DEFAULT_PANEL_STATE = {
15
15
  mode: 'confirm',
16
+ providerId: 'builtin',
16
17
  model: 'gpt-5.4',
17
18
  reasoningEffort: 'high',
18
19
  speedTier: 'standard',
@@ -177,6 +178,7 @@
177
178
  ...legacySession,
178
179
  task: state.task,
179
180
  mode: state.mode,
181
+ providerId: 'builtin',
180
182
  model: state.model,
181
183
  reasoningEffort: state.reasoningEffort,
182
184
  speedTier: state.speedTier,
@@ -208,6 +210,9 @@
208
210
  runs,
209
211
  task: sanitizeAssistantVisibleText(session.task),
210
212
  mode: VALID_MODES.has(session.mode) ? session.mode : fallbackState.mode,
213
+ providerId: typeof session.providerId === 'string' && session.providerId.trim()
214
+ ? session.providerId.trim()
215
+ : 'builtin',
211
216
  model: typeof session.model === 'string' && session.model ? session.model : fallbackState.model,
212
217
  reasoningEffort: VALID_REASONING.has(session.reasoningEffort)
213
218
  ? session.reasoningEffort
@@ -237,6 +242,7 @@
237
242
  id: `recovered_${session.id}_${index}`,
238
243
  task,
239
244
  mode: session.mode,
245
+ providerId: session.providerId || 'builtin',
240
246
  model: session.model,
241
247
  reasoningEffort: session.reasoningEffort,
242
248
  speedTier: session.speedTier,
@@ -269,6 +275,7 @@
269
275
  if (!active) {
270
276
  const session = createSession({
271
277
  mode: state.mode,
278
+ providerId: state.providerId,
272
279
  model: state.model,
273
280
  reasoningEffort: state.reasoningEffort,
274
281
  speedTier: state.speedTier,
@@ -283,12 +290,16 @@
283
290
  id: active.id,
284
291
  history: Array.isArray(active.history) ? active.history.slice(-10) : [],
285
292
  focusFiles: normalizeFocusFiles(active.focusFiles),
286
- codexThreadId: active.codexThreadId || ''
293
+ codexThreadId: active.codexThreadId || '',
294
+ providerId: active.providerId || 'builtin'
287
295
  };
288
296
  state.runs = Array.isArray(active.runs) ? active.runs : [];
289
297
  state.task = typeof active.task === 'string' ? active.task : '';
290
298
  state.focusFiles = normalizeFocusFiles(active.focusFiles);
291
299
  state.mode = VALID_MODES.has(active.mode) ? active.mode : DEFAULT_PANEL_STATE.mode;
300
+ state.providerId = typeof active.providerId === 'string' && active.providerId
301
+ ? active.providerId
302
+ : 'builtin';
292
303
  state.model = typeof active.model === 'string' && active.model ? active.model : DEFAULT_PANEL_STATE.model;
293
304
  state.reasoningEffort = VALID_REASONING.has(active.reasoningEffort)
294
305
  ? active.reasoningEffort
@@ -318,6 +329,9 @@
318
329
  runs,
319
330
  task: sanitizeAssistantVisibleText(overrides.task),
320
331
  mode: VALID_MODES.has(overrides.mode) ? overrides.mode : DEFAULT_PANEL_STATE.mode,
332
+ providerId: typeof overrides.providerId === 'string' && overrides.providerId.trim()
333
+ ? overrides.providerId.trim()
334
+ : 'builtin',
321
335
  model: typeof overrides.model === 'string' && overrides.model ? overrides.model : DEFAULT_PANEL_STATE.model,
322
336
  reasoningEffort: VALID_REASONING.has(overrides.reasoningEffort)
323
337
  ? overrides.reasoningEffort
@@ -404,6 +418,7 @@
404
418
  if (!remaining.length) {
405
419
  const replacement = createSession({
406
420
  mode: state.mode,
421
+ providerId: state.providerId,
407
422
  model: state.model,
408
423
  reasoningEffort: state.reasoningEffort,
409
424
  speedTier: state.speedTier,
@@ -589,6 +604,10 @@
589
604
  id: run.id,
590
605
  task: sanitizeAssistantVisibleText(run.task) || 'untitled task',
591
606
  mode: typeof run.mode === 'string' ? run.mode : '',
607
+ providerId: typeof run.providerId === 'string' && run.providerId ? run.providerId : 'builtin',
608
+ providerName: typeof run.providerName === 'string' ? run.providerName : '',
609
+ providerRevision: Number.isFinite(Number(run.providerRevision)) ? Number(run.providerRevision) : 0,
610
+ providerEndpointHost: typeof run.providerEndpointHost === 'string' ? run.providerEndpointHost : '',
592
611
  model: typeof run.model === 'string' ? run.model : '',
593
612
  reasoningEffort: typeof run.reasoningEffort === 'string' ? run.reasoningEffort : '',
594
613
  speedTier: typeof run.speedTier === 'string' ? run.speedTier : '',
@@ -6,7 +6,7 @@
6
6
  }
7
7
  })(typeof globalThis !== 'undefined' ? globalThis : window, function storageDbFactory(StorageRunActions) {
8
8
  'use strict';
9
- if (!StorageRunActions?.compactRunsForStorage || !StorageRunActions?.compactRunActionPayload) {
9
+ if (!StorageRunActions?.compactRunsForStorage || !StorageRunActions?.compactRunActionPayload || !StorageRunActions?.compactProviderSnapshot) {
10
10
  throw new Error('Codex Overleaf storage run-action helpers are unavailable.');
11
11
  }
12
12
  // IndexedDB versions are monotonic; v2.0 RC profiles have already opened v3.
@@ -275,7 +275,7 @@
275
275
  history: compactHistoryForStorage(input.history),
276
276
  runs: compactRunsForStorage(input.runs, options),
277
277
  task: normalizeDisplayTextForStorage(input.task, SESSION_STORAGE_LIMITS.taskChars),
278
- mode: typeof input.mode === 'string' ? input.mode : '',
278
+ mode: typeof input.mode === 'string' ? input.mode : '', providerId: typeof input.providerId === 'string' && input.providerId ? input.providerId : 'builtin',
279
279
  model: typeof input.model === 'string' ? input.model : '',
280
280
  reasoningEffort: typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
281
281
  speedTier: typeof input.speedTier === 'string' ? input.speedTier : '',
@@ -402,7 +402,7 @@
402
402
  turnId: typeof input.turnId === 'string' ? input.turnId : '',
403
403
  createdAt: typeof input.createdAt === 'string' ? input.createdAt : now,
404
404
  completedAt: typeof input.completedAt === 'string' ? input.completedAt : '',
405
- mode: typeof input.mode === 'string' ? input.mode : '',
405
+ mode: typeof input.mode === 'string' ? input.mode : '', ...StorageRunActions.compactProviderSnapshot(input),
406
406
  model: typeof input.model === 'string' ? input.model : '',
407
407
  reasoningEffort: typeof input.reasoningEffort === 'string' ? input.reasoningEffort : '',
408
408
  speedTier: typeof input.speedTier === 'string' ? input.speedTier : '',
@@ -426,7 +426,7 @@
426
426
 
427
427
  function extractLightweightPrefs(state, projectId) {
428
428
  var prefs = {
429
- storageSchemaVersion: TARGET_SCHEMA_VERSION,
429
+ storageSchemaVersion: TARGET_SCHEMA_VERSION, providerId: typeof state.providerId === 'string' && state.providerId ? state.providerId : 'builtin',
430
430
  model: typeof state.model === 'string' ? state.model : '',
431
431
  reasoningEffort: typeof state.reasoningEffort === 'string' ? state.reasoningEffort : '',
432
432
  speedTier: typeof state.speedTier === 'string' ? state.speedTier : '',
@@ -647,7 +647,7 @@
647
647
  var compact = {
648
648
  id: run.id,
649
649
  task: normalizeDisplayTextForStorage(run.task || 'untitled task', SESSION_STORAGE_LIMITS.taskChars),
650
- mode: typeof run.mode === 'string' ? redactSecretLikeText(run.mode) : '',
650
+ mode: typeof run.mode === 'string' ? redactSecretLikeText(run.mode) : '', ...StorageRunActions.compactProviderSnapshot(run),
651
651
  model: normalizeTextField(run.model, 80),
652
652
  reasoningEffort: typeof run.reasoningEffort === 'string' ? redactSecretLikeText(run.reasoningEffort) : '',
653
653
  speedTier: typeof run.speedTier === 'string' ? redactSecretLikeText(run.speedTier) : '',
@@ -81,6 +81,7 @@
81
81
  runs: Array.isArray(legacy.runs) ? legacy.runs : [],
82
82
  task: typeof legacy.task === 'string' ? legacy.task : '',
83
83
  mode: typeof legacy.mode === 'string' ? legacy.mode : legacyBlob.mode || '',
84
+ providerId: typeof legacy.providerId === 'string' && legacy.providerId ? legacy.providerId : 'builtin',
84
85
  model: typeof legacy.model === 'string' ? legacy.model : legacyBlob.model || '',
85
86
  reasoningEffort: typeof legacy.reasoningEffort === 'string' ? legacy.reasoningEffort : legacyBlob.reasoningEffort || '',
86
87
  speedTier: typeof legacy.speedTier === 'string' ? legacy.speedTier : legacyBlob.speedTier || '',
@@ -92,8 +92,18 @@
92
92
  return value.length * 2;
93
93
  }
94
94
 
95
+ function compactProviderSnapshot(value) {
96
+ return {
97
+ providerId: typeof value.providerId === 'string' && value.providerId ? value.providerId : 'builtin',
98
+ providerName: typeof value.providerName === 'string' ? value.providerName : '',
99
+ providerRevision: Number.isFinite(Number(value.providerRevision)) ? Number(value.providerRevision) : 0,
100
+ providerEndpointHost: typeof value.providerEndpointHost === 'string' ? value.providerEndpointHost : ''
101
+ };
102
+ }
103
+
95
104
  return {
96
105
  compactRunsForStorage: compactRunsForStorage,
97
- compactRunActionPayload: compactRunActionPayload
106
+ compactRunActionPayload: compactRunActionPayload,
107
+ compactProviderSnapshot: compactProviderSnapshot
98
108
  };
99
109
  });
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ const RUNTIME_ENV_KEY = 'CODEX_OVERLEAF_CODEX_RUNTIME_JSON';
9
+ const MAX_CODEX_CANDIDATES = 8;
10
+ const VERSION_TIMEOUT_MS = 700;
11
+
12
+ function buildCodexRuntimeIdentity(options = {}) {
13
+ const env = options.env || process.env;
14
+ const platform = options.platform || process.platform;
15
+ const delimiter = options.delimiter || (platform === 'win32' ? ';' : path.delimiter);
16
+ const selectedPath = String(options.selectedPath || '').trim();
17
+ const discovered = discoverCodexCandidates(options.pathValue || env.PATH || '', {
18
+ delimiter,
19
+ env,
20
+ platform
21
+ });
22
+ const candidatePaths = selectedPath
23
+ ? [selectedPath, ...discovered.filter(candidate => !sameExecutablePath(candidate, selectedPath, platform))]
24
+ : discovered;
25
+ const candidates = candidatePaths.slice(0, MAX_CODEX_CANDIDATES).map((candidatePath, index) => ({
26
+ path: candidatePath,
27
+ displayPath: compactHomePath(candidatePath, env, platform),
28
+ version: readCodexVersion(candidatePath, { env, platform }),
29
+ source: classifyCodexSource(candidatePath),
30
+ selected: index === 0 && Boolean(selectedPath)
31
+ }));
32
+ const selected = candidates.find(candidate => candidate.selected) || null;
33
+
34
+ return {
35
+ schemaVersion: 1,
36
+ selected,
37
+ candidates,
38
+ multipleInstallations: candidates.length > 1,
39
+ selectedBy: selected ? 'path-order' : 'not-found'
40
+ };
41
+ }
42
+
43
+ function discoverCodexCandidates(pathValue, options = {}) {
44
+ const platform = options.platform || process.platform;
45
+ const delimiter = options.delimiter || (platform === 'win32' ? ';' : path.delimiter);
46
+ const extensions = executableExtensions(options.env || process.env, platform);
47
+ const seen = new Set();
48
+ const result = [];
49
+
50
+ for (const rawSegment of String(pathValue || '').split(delimiter)) {
51
+ const segment = rawSegment.trim();
52
+ if (!segment) continue;
53
+ for (const extension of extensions) {
54
+ const candidate = path.join(segment, `codex${extension}`);
55
+ const key = platform === 'win32' ? candidate.toLowerCase() : candidate;
56
+ if (seen.has(key) || !isExecutableFile(candidate, platform)) continue;
57
+ seen.add(key);
58
+ result.push(candidate);
59
+ break;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+
65
+ function executableExtensions(env, platform) {
66
+ if (platform !== 'win32') return [''];
67
+ const configured = String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
68
+ .split(';')
69
+ .map(value => value.trim().toLowerCase())
70
+ .filter(Boolean);
71
+ return ['', ...new Set(configured)];
72
+ }
73
+
74
+ function isExecutableFile(candidate, platform) {
75
+ try {
76
+ if (!fs.statSync(candidate).isFile()) return false;
77
+ fs.accessSync(candidate, platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ function readCodexVersion(candidatePath, options = {}) {
85
+ if (!candidatePath) return '';
86
+ const platform = options.platform || process.platform;
87
+ const result = spawnSync(candidatePath, ['--version'], {
88
+ encoding: 'utf8',
89
+ env: options.env || process.env,
90
+ maxBuffer: 64 * 1024,
91
+ shell: platform === 'win32' && /\.(?:cmd|bat)$/i.test(candidatePath),
92
+ timeout: VERSION_TIMEOUT_MS,
93
+ windowsHide: true
94
+ });
95
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim();
96
+ const match = output.match(/\bcodex(?:-cli)?\s+v?([^\s]+)/i);
97
+ return match ? match[1] : '';
98
+ }
99
+
100
+ function classifyCodexSource(candidatePath) {
101
+ const normalized = normalizeSlashes(candidatePath).toLowerCase();
102
+ let resolved = normalized;
103
+ try {
104
+ resolved = normalizeSlashes(fs.realpathSync(candidatePath)).toLowerCase();
105
+ } catch {
106
+ // Keep the visible path when a symlink target cannot be resolved.
107
+ }
108
+ if (normalized.includes('/applications/chatgpt.app/')) return 'chatgpt-app';
109
+ if (normalized.includes('/applications/codex.app/')) return 'codex-app';
110
+ if (normalized.includes('/.nvm/versions/node/')) return 'npm-nvm';
111
+ if (resolved.includes('/node_modules/@openai/codex')) return 'npm-global';
112
+ if (normalized.includes('/opt/homebrew/') || normalized.includes('/homebrew/')) return 'homebrew-prefix';
113
+ if (normalized.includes('/.npm-global/')) return 'npm-global';
114
+ return 'path';
115
+ }
116
+
117
+ function compactHomePath(candidatePath, env = process.env, platform = process.platform) {
118
+ const home = String(env.HOME || env.USERPROFILE || os.homedir() || '');
119
+ if (!home) return candidatePath;
120
+ const caseFold = value => platform === 'win32' ? value.toLowerCase() : value;
121
+ const normalizedCandidate = path.normalize(candidatePath);
122
+ const normalizedHome = path.normalize(home);
123
+ if (caseFold(normalizedCandidate) === caseFold(normalizedHome)) return '~';
124
+ if (caseFold(normalizedCandidate).startsWith(`${caseFold(normalizedHome)}${path.sep}`)) {
125
+ return `~${path.sep}${normalizedCandidate.slice(normalizedHome.length + 1)}`;
126
+ }
127
+ return candidatePath;
128
+ }
129
+
130
+ function serializeCodexRuntimeIdentity(identity) {
131
+ return JSON.stringify(identity || emptyIdentity());
132
+ }
133
+
134
+ function getCodexRuntimeIdentityFromEnv(env = process.env) {
135
+ try {
136
+ const parsed = JSON.parse(String(env[RUNTIME_ENV_KEY] || ''));
137
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.candidates)) return parsed;
138
+ } catch {
139
+ // Fall through to the legacy single-path representation.
140
+ }
141
+ const selectedPath = String(env.CODEX_OVERLEAF_CODEX_PATH || '').trim();
142
+ const selected = selectedPath ? {
143
+ path: selectedPath,
144
+ displayPath: compactHomePath(selectedPath, env, env.CODEX_OVERLEAF_PLATFORM || process.platform),
145
+ version: String(env.CODEX_OVERLEAF_CODEX_VERSION || ''),
146
+ source: String(env.CODEX_OVERLEAF_CODEX_SOURCE || classifyCodexSource(selectedPath)),
147
+ selected: true
148
+ } : null;
149
+ return {
150
+ schemaVersion: 1,
151
+ selected,
152
+ candidates: selected ? [selected] : [],
153
+ multipleInstallations: false,
154
+ selectedBy: selected ? 'legacy-path' : 'not-found'
155
+ };
156
+ }
157
+
158
+ function buildCodexRuntimeEvent(env = process.env) {
159
+ const identity = getCodexRuntimeIdentityFromEnv(env);
160
+ const selected = identity.selected;
161
+ if (!selected) return null;
162
+ const version = selected.version || 'unknown version';
163
+ const candidateCount = identity.candidates.length;
164
+ return {
165
+ type: 'codex.runtime.selected',
166
+ title: identity.multipleInstallations
167
+ ? `Using Codex CLI ${version}; ${candidateCount} installations were detected.`
168
+ : `Using Codex CLI ${version}.`,
169
+ status: 'completed',
170
+ detail: {
171
+ path: selected.displayPath || selected.path,
172
+ version: selected.version || '',
173
+ source: selected.source || 'path',
174
+ selectedBy: identity.selectedBy || 'path-order',
175
+ multipleInstallations: identity.multipleInstallations === true,
176
+ warningCode: identity.multipleInstallations ? 'multiple_codex_installations' : '',
177
+ candidates: identity.candidates.map(candidate => ({
178
+ path: candidate.displayPath || candidate.path,
179
+ version: candidate.version || '',
180
+ source: candidate.source || 'path',
181
+ selected: candidate.selected === true
182
+ })),
183
+ technical: true
184
+ }
185
+ };
186
+ }
187
+
188
+ function sameExecutablePath(left, right, platform) {
189
+ const normalize = value => path.normalize(String(value || ''));
190
+ return platform === 'win32'
191
+ ? normalize(left).toLowerCase() === normalize(right).toLowerCase()
192
+ : normalize(left) === normalize(right);
193
+ }
194
+
195
+ function normalizeSlashes(value) {
196
+ return String(value || '').replace(/\\/g, '/');
197
+ }
198
+
199
+ function emptyIdentity() {
200
+ return {
201
+ schemaVersion: 1,
202
+ selected: null,
203
+ candidates: [],
204
+ multipleInstallations: false,
205
+ selectedBy: 'not-found'
206
+ };
207
+ }
208
+
209
+ module.exports = {
210
+ RUNTIME_ENV_KEY,
211
+ buildCodexRuntimeEvent,
212
+ buildCodexRuntimeIdentity,
213
+ discoverCodexCandidates,
214
+ getCodexRuntimeIdentityFromEnv,
215
+ readCodexVersion,
216
+ serializeCodexRuntimeIdentity
217
+ };
@@ -56,6 +56,7 @@ async function runDoctor(options = {}) {
56
56
  errors: []
57
57
  },
58
58
  bridge: null,
59
+ codex: null,
59
60
  ping: {
60
61
  attempted: false
61
62
  },
@@ -106,6 +107,10 @@ async function runDoctor(options = {}) {
106
107
  body.ping = pingResult;
107
108
 
108
109
  if (body.ping.ok) {
110
+ body.codex = sanitizeCodexRuntime(
111
+ rawPingResponse?.result?.environment?.codex,
112
+ pathOptions
113
+ );
109
114
  body.compatibility = sanitizeCompatibility(
110
115
  evaluateNativeCompatibility(rawPingResponse, { version: PACKAGE_VERSION })
111
116
  );
@@ -206,6 +211,19 @@ function formatDoctorHuman(body = {}) {
206
211
  lines.push(`Native compatibility: ${body.compatibility.classification || 'unknown'}`);
207
212
  lines.push(`Native version: ${body.compatibility.nativeVersion || 'unknown'}`);
208
213
  }
214
+ if (body.codex) {
215
+ lines.push(`Codex status: ${body.codex.ok ? 'available' : 'not found'}`);
216
+ lines.push(`Codex path: ${body.codex.path || 'unknown'}`);
217
+ lines.push(`Codex version: ${body.codex.version || 'unknown'}`);
218
+ lines.push(`Codex source: ${body.codex.source || 'unknown'}`);
219
+ if (body.codex.multipleInstallations) {
220
+ lines.push('Warning: multiple Codex installations detected.');
221
+ lines.push('Codex candidates:');
222
+ for (const candidate of body.codex.candidates) {
223
+ lines.push(` ${candidate.selected ? '*' : '-'} ${candidate.path || 'unknown'} (${candidate.version || 'unknown'}, ${candidate.source || 'path'})`);
224
+ }
225
+ }
226
+ }
209
227
  if (body.ping?.ok === false) {
210
228
  lines.push(`Ping: failed (${body.ping.error || 'unknown error'})`);
211
229
  } else if (body.ping?.ok === true) {
@@ -255,6 +273,28 @@ function normalizeDiagnosticPath(targetPath, options = {}) {
255
273
  return value;
256
274
  }
257
275
 
276
+ function sanitizeCodexRuntime(codex, pathOptions) {
277
+ if (!codex || typeof codex !== 'object') return null;
278
+ const normalizeCodexPath = value => {
279
+ const normalized = normalizeDiagnosticPath(value, pathOptions);
280
+ return normalized.includes('<absolute-path>') ? String(value || '') : normalized;
281
+ };
282
+ return {
283
+ ok: codex.ok === true,
284
+ path: normalizeCodexPath(codex.path || ''),
285
+ version: String(codex.version || ''),
286
+ source: String(codex.source || ''),
287
+ selectedBy: String(codex.selectedBy || ''),
288
+ multipleInstallations: codex.multipleInstallations === true,
289
+ candidates: Array.isArray(codex.candidates) ? codex.candidates.map(candidate => ({
290
+ path: normalizeCodexPath(candidate?.path || ''),
291
+ version: String(candidate?.version || ''),
292
+ source: String(candidate?.source || ''),
293
+ selected: candidate?.selected === true
294
+ })) : []
295
+ };
296
+ }
297
+
258
298
  function finalizeDoctorResult(body) {
259
299
  const classification = classifyDoctorResult(body);
260
300
  body.ok = classification.ok;
@@ -4,6 +4,11 @@ const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
6
  const { spawnSync } = require('node:child_process');
7
+ const {
8
+ buildCodexRuntimeIdentity,
9
+ getCodexRuntimeIdentityFromEnv,
10
+ serializeCodexRuntimeIdentity
11
+ } = require('./codexRuntimeIdentity');
7
12
 
8
13
  const TOOL_NAMES = ['codex', 'latexmk', 'pdflatex', 'xelatex', 'lualatex', 'bibtex', 'biber'];
9
14
  const LATEX_TOOL_NAMES = ['latexmk', 'pdflatex', 'xelatex', 'lualatex', 'bibtex', 'biber'];
@@ -32,6 +37,16 @@ function buildNativeRuntimeEnv(baseEnv = process.env, options = {}) {
32
37
  for (const tool of TOOL_NAMES) {
33
38
  env[getToolEnvName(tool)] = resolveExecutable(tool, env.PATH, { ...runtimeOptions, env }) || '';
34
39
  }
40
+ const codexRuntime = buildCodexRuntimeIdentity({
41
+ selectedPath: env.CODEX_OVERLEAF_CODEX_PATH,
42
+ pathValue: env.PATH,
43
+ delimiter: runtimeOptions.delimiter,
44
+ env,
45
+ platform: runtimeOptions.platform
46
+ });
47
+ env.CODEX_OVERLEAF_CODEX_RUNTIME_JSON = serializeCodexRuntimeIdentity(codexRuntime);
48
+ env.CODEX_OVERLEAF_CODEX_VERSION = codexRuntime.selected?.version || '';
49
+ env.CODEX_OVERLEAF_CODEX_SOURCE = codexRuntime.selected?.source || '';
35
50
 
36
51
  return env;
37
52
  }
@@ -200,13 +215,25 @@ function resolveExecutable(name, pathValue, options = {}) {
200
215
  }
201
216
 
202
217
  function summarizeNativeEnvironment(env = process.env, options = {}) {
218
+ const codexRuntime = getCodexRuntimeIdentityFromEnv(env);
219
+ const selectedCodex = codexRuntime.selected || {};
203
220
  const runtimeOptions = { ...options, platform: getNativeRuntimePlatform({ ...options, env }) };
204
221
  const availableLatex = LATEX_TOOL_NAMES.filter(tool => Boolean(env[getToolEnvName(tool)]));
205
222
  const missingLatex = LATEX_TOOL_NAMES.filter(tool => !env[getToolEnvName(tool)]);
206
223
  return {
207
224
  codex: {
208
225
  ok: Boolean(env.CODEX_OVERLEAF_CODEX_PATH),
209
- path: env.CODEX_OVERLEAF_CODEX_PATH || ''
226
+ path: env.CODEX_OVERLEAF_CODEX_PATH || '',
227
+ version: selectedCodex.version || '',
228
+ source: selectedCodex.source || '',
229
+ selectedBy: codexRuntime.selectedBy || 'not-found',
230
+ multipleInstallations: codexRuntime.multipleInstallations === true,
231
+ candidates: codexRuntime.candidates.map(candidate => ({
232
+ path: candidate.displayPath || candidate.path || '',
233
+ version: candidate.version || '',
234
+ source: candidate.source || 'path',
235
+ selected: candidate.selected === true
236
+ }))
210
237
  },
211
238
  latex: {
212
239
  ok: availableLatex.length > 0,
@@ -213,12 +213,24 @@ function cancelProviderTest(operationId) {
213
213
 
214
214
  function resolveProviderModels(params = {}, env = process.env, resolveBuiltInModels) {
215
215
  const state = loadProviderState(env);
216
- if (state.public.activeProviderId === 'builtin') {
217
- return resolveBuiltInModels(params, env);
216
+ const selection = params.providerSelection;
217
+ const requestedProviderId = selection?.providerId || state.public.activeProviderId || 'builtin';
218
+ if (requestedProviderId === 'builtin') {
219
+ const result = resolveBuiltInModels(params, env);
220
+ const attachProvider = value => ({
221
+ ...(value || {}),
222
+ providerId: 'builtin',
223
+ providerRevision: 0,
224
+ providerName: 'Built-in Codex'
225
+ });
226
+ return result && typeof result.then === 'function' ? result.then(attachProvider) : attachProvider(result);
218
227
  }
219
- const profile = state.public.profiles.find(item => item.id === state.public.activeProviderId);
228
+ const profile = state.public.profiles.find(item => item.id === requestedProviderId);
220
229
  if (!profile) {
221
- throw providerError('provider_not_found', 'The active provider no longer exists.');
230
+ throw providerError('provider_not_found', 'The selected provider no longer exists.');
231
+ }
232
+ if (selection?.providerId && Number(selection.providerRevision) !== profile.revision) {
233
+ throw providerError('provider_revision_conflict', 'The selected provider changed. Refresh its model list and retry.');
222
234
  }
223
235
  return {
224
236
  providerId: profile.id,
@@ -245,10 +257,8 @@ function resolveRunProvider(params = {}, env = process.env) {
245
257
  const state = loadProviderState(env);
246
258
  const selection = params.providerSelection;
247
259
  const activeProviderId = state.public.activeProviderId || 'builtin';
248
- if (selection?.providerId && selection.providerId !== activeProviderId) {
249
- throw providerError('provider_revision_conflict', 'The active provider changed in another tab. Refresh and retry.');
250
- }
251
- if (activeProviderId === 'builtin') {
260
+ const requestedProviderId = selection?.providerId || activeProviderId;
261
+ if (requestedProviderId === 'builtin') {
252
262
  return {
253
263
  modelId: params.model || '',
254
264
  reasoningEffort: params.reasoningEffort || '',
@@ -256,15 +266,15 @@ function resolveRunProvider(params = {}, env = process.env) {
256
266
  providerSelection: { providerId: 'builtin', providerRevision: 0 }
257
267
  };
258
268
  }
259
- const profile = state.public.profiles.find(item => item.id === activeProviderId);
269
+ const profile = state.public.profiles.find(item => item.id === requestedProviderId);
260
270
  if (!profile) {
261
- throw providerError('provider_not_found', 'The active provider no longer exists.');
271
+ throw providerError('provider_not_found', 'The selected provider no longer exists.');
262
272
  }
263
273
  if (!selection?.providerId) {
264
274
  throw providerError('provider_selection_unavailable', 'The active provider has not been confirmed by this Overleaf tab. Wait for Provider settings to load and retry.');
265
275
  }
266
276
  if (Number(selection.providerRevision) !== profile.revision) {
267
- throw providerError('provider_revision_conflict', 'The active provider changed in another tab. Refresh and retry.');
277
+ throw providerError('provider_revision_conflict', 'The selected provider changed. Refresh its model list and retry.');
268
278
  }
269
279
  const endpointHost = getEndpointHost(profile.baseUrl);
270
280
  if (profile.endpointDisclosureHost !== endpointHost || profile.endpointDisclosureBaseUrl !== profile.baseUrl) {
@@ -302,7 +312,9 @@ function resolveRunProvider(params = {}, env = process.env) {
302
312
  reasoningEffort,
303
313
  upstreamResponseMode: validDiagnostic ? diagnostic.upstreamResponseMode : ''
304
314
  }),
305
- providerSelection: { providerId: profile.id, providerRevision: profile.revision }
315
+ providerSelection: { providerId: profile.id, providerRevision: profile.revision },
316
+ providerName: profile.name,
317
+ providerEndpointHost: endpointHost
306
318
  };
307
319
  }
308
320
 
@@ -315,9 +327,10 @@ async function resolveRunProviderForRun(params = {}, env = process.env, options
315
327
  }
316
328
  }
317
329
  const state = loadProviderState(env);
318
- const profile = state.public.profiles.find(item => item.id === state.public.activeProviderId);
330
+ const requestedProviderId = params.providerSelection?.providerId || state.public.activeProviderId || 'builtin';
331
+ const profile = state.public.profiles.find(item => item.id === requestedProviderId);
319
332
  if (!profile) {
320
- throw providerError('provider_not_found', 'The active provider no longer exists.');
333
+ throw providerError('provider_not_found', 'The selected provider no longer exists.');
321
334
  }
322
335
  const modelId = String(params.model || profile.defaultModelId).trim();
323
336
  options.emit?.({
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { buildCodexRuntimeEvent } = require('./codexRuntimeIdentity');
4
+
3
5
  const { spawn } = require('node:child_process');
4
6
  const crypto = require('node:crypto');
5
7
  const { buildOperationSummary, splitDeletePlan } = require('../../extension/src/shared/summary');
@@ -157,6 +159,10 @@ async function handleCodexRun(request, env, emit) {
157
159
  'Codex CLI was not found. Install Codex or make sure the `codex` command is available in your login shell.'
158
160
  );
159
161
  }
162
+ const codexRuntimeEvent = buildCodexRuntimeEvent(env);
163
+ if (codexRuntimeEvent && typeof emit === 'function') {
164
+ emit(codexRuntimeEvent);
165
+ }
160
166
 
161
167
  const projectKey = resolveProjectKey(params);
162
168
  const lockToken = acquireProjectLock(projectKey);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-overleaf-link",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Cross-platform Chrome bridge that connects Codex to the active Overleaf project.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",