@jacobbd/relay-ai 0.8.0 → 0.9.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.
@@ -2,6 +2,7 @@ import {
2
2
  formatModelPrice,
3
3
  getProviderModelPage,
4
4
  isFreeModel,
5
+ matchesModelSearch,
5
6
  PROVIDER_MODEL_PAGE_SIZE,
6
7
  } from './provider-model-browser.js';
7
8
  import { copyDeviceCode, copyTextToClipboard, oauthConnectionLabel } from './oauth-device.js';
@@ -11,6 +12,7 @@ import { providerInitial, providerLogoHtml } from './provider-logo.js';
11
12
 
12
13
  const AGY_MAX = 6;
13
14
  const GENERAL_MAX = 20;
15
+ const CODEX_SUBAGENT_MAX = 1;
14
16
  const UPDATE_COMMAND = 'npm install -g @jacobbd/relay-ai@latest';
15
17
 
16
18
  const state = {
@@ -19,6 +21,7 @@ const state = {
19
21
  allModels: [],
20
22
  appModelsByTarget: {}, // launch target → flattened, compatibility-filtered model list
21
23
  generalFavorites: [],
24
+ codexSubagentModels: [],
22
25
  agyFavorites: [],
23
26
  modelsLoaded: false,
24
27
  modelsError: null,
@@ -32,6 +35,8 @@ const state = {
32
35
  modelFreeOnly: false,
33
36
  agyFilter: '',
34
37
  agyFreeOnly: false,
38
+ codexSubagentFilter: '',
39
+ codexSubagentFreeOnly: false,
35
40
  appModelFilter: '',
36
41
  appFreeOnly: false,
37
42
  providerNameMap: {}, // providerId → full display name
@@ -41,6 +46,7 @@ const state = {
41
46
  appModelOpen: null,
42
47
  appSelections: {},
43
48
  appHttpProxy: {},
49
+ appWithNative: {},
44
50
  server: {
45
51
  status: null,
46
52
  error: null,
@@ -185,6 +191,7 @@ async function api(method, path, body) {
185
191
  async function loadConfig() {
186
192
  const data = await api('GET', '/api/config');
187
193
  state.generalFavorites = data.favoriteModels ?? [];
194
+ state.codexSubagentModels = (data.codexSubagentModels ?? []).slice(0, CODEX_SUBAGENT_MAX);
188
195
  state.agyFavorites = data.antigravityCliFavoriteModels ?? [];
189
196
  }
190
197
 
@@ -264,7 +271,7 @@ function flattenModelProviders(providers) {
264
271
  // Mirrors src/ui/api.ts's APP_ID_TO_LAUNCH_TARGET — which models are compatible with
265
272
  // which app depends on the launch target, not just the raw catalog.
266
273
  const APP_ID_NEEDS_TARGET_FILTER = new Set([
267
- 'claude', 'claude-app', 'codex', 'codex-app', 'gemini', 'agy', 'antigravity', 'antigravity-ide',
274
+ 'claude', 'claude-app', 'codex', 'codex-app', 'codex-subagents', 'gemini', 'agy', 'antigravity', 'antigravity-ide',
268
275
  ]);
269
276
 
270
277
  async function loadAppModels(appId) {
@@ -306,8 +313,12 @@ async function loadModels() {
306
313
  }
307
314
 
308
315
  async function saveFavorites(payload) { return api('POST', '/api/config', payload); }
309
- async function saveKey(providerId, key) { return api('POST', '/api/keys', { providerId, key }); }
310
- async function refreshProvider(providerId) { return api('POST', '/api/providers/refresh', { providerId }); }
316
+ async function saveKey(providerId, key, confirmOverwrite = false) {
317
+ return api('POST', '/api/keys', { providerId, key, confirmOverwrite });
318
+ }
319
+ async function refreshProvider(providerId, key) {
320
+ return api('POST', '/api/providers/refresh', key ? { providerId, key } : { providerId });
321
+ }
311
322
 
312
323
  // ─── Toast ────────────────────────────────────────────────────────────────────
313
324
 
@@ -521,8 +532,10 @@ function toggleModelFavoriteMenu(button, providerId, modelId) {
521
532
 
522
533
  const isGenFav = isGeneralFavorite(providerId, modelId);
523
534
  const isAgyFav = state.agyFavorites.some(f => f.providerId === providerId && f.modelId === modelId);
535
+ const isSubagent = state.codexSubagentModels.some(f => f.providerId === providerId && f.modelId === modelId);
524
536
  const genAtCapacity = state.generalFavorites.length >= GENERAL_MAX;
525
537
  const agyAtCapacity = state.agyFavorites.length >= AGY_MAX;
538
+ const subagentAtCapacity = state.codexSubagentModels.length >= CODEX_SUBAGENT_MAX;
526
539
 
527
540
  const popover = document.createElement('div');
528
541
  popover.className = 'model-fav-popover';
@@ -538,6 +551,11 @@ function toggleModelFavoriteMenu(button, providerId, modelId) {
538
551
  if (isAgyFav) agyLabel = '✓ In Antigravity Favorites';
539
552
  else if (agyAtCapacity) agyLabel = `✦ Antigravity full (${AGY_MAX}/${AGY_MAX})`;
540
553
 
554
+ const subagentDisabled = isSubagent || subagentAtCapacity;
555
+ let subagentLabel = '✦ Add to Codex SubAgent';
556
+ if (isSubagent) subagentLabel = '✓ In Codex SubAgent';
557
+ else if (subagentAtCapacity) subagentLabel = `✦ Codex SubAgent full (${CODEX_SUBAGENT_MAX}/${CODEX_SUBAGENT_MAX})`;
558
+
541
559
  popover.innerHTML = `
542
560
  <button class="model-fav-popover-item ${genDisabled ? 'disabled' : ''}" type="button" data-type="general" ${genDisabled ? 'disabled' : ''}>
543
561
  <span>${genLabel}</span>
@@ -547,6 +565,10 @@ function toggleModelFavoriteMenu(button, providerId, modelId) {
547
565
  <span>${agyLabel}</span>
548
566
  <span class="popover-slot-count">${state.agyFavorites.length}/${AGY_MAX}</span>
549
567
  </button>
568
+ <button class="model-fav-popover-item ${subagentDisabled ? 'disabled' : ''}" type="button" data-type="codex-subagents" ${subagentDisabled ? 'disabled' : ''}>
569
+ <span>${subagentLabel}</span>
570
+ <span class="popover-slot-count">${state.codexSubagentModels.length}/${CODEX_SUBAGENT_MAX}</span>
571
+ </button>
550
572
  `;
551
573
 
552
574
  popover.querySelectorAll('.model-fav-popover-item').forEach(item => {
@@ -555,7 +577,13 @@ function toggleModelFavoriteMenu(button, providerId, modelId) {
555
577
  const listType = item.dataset.type;
556
578
  addToFavorites({ providerId, modelId }, listType);
557
579
  closeModelFavPopover();
558
- showToast(listType === 'agy' ? 'Added to Antigravity Favorites' : 'Added to Global Favorites');
580
+ showToast(
581
+ listType === 'agy'
582
+ ? 'Added to Antigravity Favorites'
583
+ : listType === 'codex-subagents'
584
+ ? 'Added to Codex SubAgent'
585
+ : 'Added to Global Favorites',
586
+ );
559
587
  renderProviderModelBrowser();
560
588
  });
561
589
  });
@@ -1298,33 +1326,34 @@ function buildProviderBodyContent(provider) {
1298
1326
  content.appendChild(keyRow);
1299
1327
  content.appendChild(feedback);
1300
1328
 
1301
- async function doSave(key) {
1302
- if (!key.trim()) return;
1303
- const result = await saveKey(provider.id, key);
1304
- if (result.ok) {
1305
- feedback.textContent = '✓ Key saved to keychain';
1306
- feedback.className = 'key-feedback success';
1307
- provider.hasKey = true;
1308
- const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
1309
- if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
1310
- } else {
1311
- feedback.textContent = result.error ?? 'Failed to save key';
1312
- feedback.className = 'key-feedback error';
1313
- }
1314
- setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 3500);
1315
- }
1316
-
1317
- input.addEventListener('blur', () => { if (input.value) doSave(input.value); });
1318
-
1319
1329
  testBtn.addEventListener('click', async () => {
1320
- if (input.value) await doSave(input.value);
1330
+ const enteredKey = input.value.trim();
1321
1331
  feedback.textContent = 'Refreshing…';
1322
1332
  feedback.className = 'key-feedback muted';
1323
1333
  testBtn.disabled = true;
1324
- const result = await refreshProvider(provider.id);
1334
+ const result = await refreshProvider(provider.id, enteredKey || undefined);
1325
1335
  testBtn.disabled = false;
1326
1336
  if (result.ok) {
1327
- feedback.textContent = `✓ ${result.count} models available`;
1337
+ let savedMessage = '';
1338
+ if (enteredKey && window.confirm('Save this API key for future Relay launches?')) {
1339
+ let saved = await saveKey(provider.id, enteredKey);
1340
+ if (saved.needsConfirmation && window.confirm('Replace the existing stored API key?')) {
1341
+ saved = await saveKey(provider.id, enteredKey, true);
1342
+ }
1343
+ if (saved.ok) {
1344
+ savedMessage = ' · key saved';
1345
+ provider.hasKey = true;
1346
+ const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
1347
+ if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
1348
+ } else if (saved.needsConfirmation) {
1349
+ savedMessage = ' · key used for this refresh only';
1350
+ } else {
1351
+ feedback.textContent = saved.error ?? 'Failed to save key';
1352
+ feedback.className = 'key-feedback error';
1353
+ return;
1354
+ }
1355
+ }
1356
+ feedback.textContent = `✓ ${result.count} models available${savedMessage}`;
1328
1357
  feedback.className = 'key-feedback success';
1329
1358
  const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
1330
1359
  if (countEl) countEl.textContent = `${result.count} models`;
@@ -1480,11 +1509,14 @@ function buildOAuthProviderBodyContent(provider) {
1480
1509
  // ─── Model search results ─────────────────────────────────────────────────────
1481
1510
 
1482
1511
  function buildModelResults(filter, listType) {
1483
- const containerId = listType === 'agy' ? 'agy-results' : 'model-results';
1512
+ const containerId = listType === 'agy' ? 'agy-results' : listType === 'codex-subagents' ? 'codex-subagent-results' : 'model-results';
1484
1513
  const container = document.getElementById(containerId);
1485
- const currentFavs = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1486
- const atCapacity = listType === 'agy' && currentFavs.length >= AGY_MAX;
1487
- const freeOnly = listType === 'agy' ? state.agyFreeOnly : state.modelFreeOnly;
1514
+ const currentFavs = listType === 'agy'
1515
+ ? state.agyFavorites
1516
+ : listType === 'codex-subagents' ? state.codexSubagentModels : state.generalFavorites;
1517
+ const max = listType === 'agy' ? AGY_MAX : listType === 'codex-subagents' ? CODEX_SUBAGENT_MAX : GENERAL_MAX;
1518
+ const atCapacity = currentFavs.length >= max;
1519
+ const freeOnly = listType === 'agy' ? state.agyFreeOnly : listType === 'codex-subagents' ? state.codexSubagentFreeOnly : state.modelFreeOnly;
1488
1520
 
1489
1521
  if (!filter) { container.hidden = true; return; }
1490
1522
  container.hidden = false;
@@ -1503,13 +1535,22 @@ function buildModelResults(filter, listType) {
1503
1535
  return;
1504
1536
  }
1505
1537
 
1538
+ if (listType === 'codex-subagents' && !Object.prototype.hasOwnProperty.call(state.appModelsByTarget, 'codex-subagents')) {
1539
+ container.innerHTML = Array(3).fill('<div class="skeleton" style="height:36px;margin:4px 12px;border-radius:6px"></div>').join('');
1540
+ loadAppModels('codex-subagents').then(() => buildModelResults(filter, listType));
1541
+ return;
1542
+ }
1543
+
1506
1544
  const q = filter.trim().toLowerCase();
1507
- const base = state.allModels.filter(m => !freeOnly || isFreeModel(m));
1545
+ const sourceModels = listType === 'codex-subagents'
1546
+ ? state.appModelsByTarget['codex-subagents']
1547
+ : state.allModels;
1548
+ const base = sourceModels.filter(m => !freeOnly || isFreeModel(m));
1508
1549
  const matched = base.filter(m =>
1509
1550
  !q ||
1510
- m.id.toLowerCase().includes(q) ||
1511
- (m.name && m.name.toLowerCase().includes(q)) ||
1512
- m.providerName.toLowerCase().includes(q)
1551
+ matchesModelSearch(m.id, q) ||
1552
+ (m.name && matchesModelSearch(m.name, q)) ||
1553
+ matchesModelSearch(m.providerName, q)
1513
1554
  ).slice(0, freeOnly && !q ? 80 : 40);
1514
1555
 
1515
1556
  if (matched.length === 0) {
@@ -1554,7 +1595,7 @@ function buildModelResults(filter, listType) {
1554
1595
  addBtn.className = 'btn-add' + (isFav ? ' already-added' : '');
1555
1596
  addBtn.textContent = isFav ? '✓' : '+';
1556
1597
  addBtn.disabled = isFav || (!isFav && atCapacity);
1557
- addBtn.title = atCapacity && !isFav ? `Antigravity is full (${AGY_MAX}/${AGY_MAX})` : (isFav ? 'Already added' : 'Add to favorites');
1598
+ addBtn.title = atCapacity && !isFav ? `${listType === 'codex-subagents' ? 'Codex SubAgent' : listType === 'agy' ? 'Antigravity' : 'Favorites'} is full (${max}/${max})` : (isFav ? 'Already added' : 'Add to favorites');
1558
1599
  if (!isFav && !atCapacity) {
1559
1600
  addBtn.addEventListener('click', () => {
1560
1601
  addToFavorites({ providerId: m.providerId, modelId: m.id }, listType);
@@ -1576,11 +1617,20 @@ function buildModelResults(filter, listType) {
1576
1617
  function addToFavorites(fav, listType) {
1577
1618
  if (listType === 'agy') {
1578
1619
  if (state.agyFavorites.length >= AGY_MAX) return;
1620
+ if (state.agyFavorites.some(item => item.providerId === fav.providerId && item.modelId === fav.modelId)) return;
1579
1621
  state.agyFavorites = [...state.agyFavorites, fav];
1580
1622
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1581
1623
  renderAgyList();
1582
1624
  updateAgyCounter();
1625
+ } else if (listType === 'codex-subagents') {
1626
+ if (state.codexSubagentModels.length >= CODEX_SUBAGENT_MAX) return;
1627
+ if (state.codexSubagentModels.some(item => item.providerId === fav.providerId && item.modelId === fav.modelId)) return;
1628
+ state.codexSubagentModels = [...state.codexSubagentModels, fav];
1629
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1630
+ renderCodexSubagentList();
1631
+ updateCodexSubagentCounter();
1583
1632
  } else {
1633
+ if (state.generalFavorites.some(item => item.providerId === fav.providerId && item.modelId === fav.modelId)) return;
1584
1634
  state.generalFavorites = [...state.generalFavorites, fav];
1585
1635
  saveFavorites({ favoriteModels: state.generalFavorites });
1586
1636
  renderFavList();
@@ -1598,6 +1648,16 @@ function removeFromFavorites(index, listType) {
1598
1648
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1599
1649
  renderAgyList(); updateAgyCounter();
1600
1650
  });
1651
+ } else if (listType === 'codex-subagents') {
1652
+ const prev = [...state.codexSubagentModels];
1653
+ state.codexSubagentModels = state.codexSubagentModels.filter((_, i) => i !== index);
1654
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1655
+ renderCodexSubagentList(); updateCodexSubagentCounter();
1656
+ showToast('Removed from Codex SubAgent', () => {
1657
+ state.codexSubagentModels = prev;
1658
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1659
+ renderCodexSubagentList(); updateCodexSubagentCounter();
1660
+ });
1601
1661
  } else {
1602
1662
  const prev = [...state.generalFavorites];
1603
1663
  state.generalFavorites = state.generalFavorites.filter((_, i) => i !== index);
@@ -1612,7 +1672,7 @@ function removeFromFavorites(index, listType) {
1612
1672
  }
1613
1673
 
1614
1674
  function reorderFavorites(from, to, listType) {
1615
- const arr = listType === 'agy' ? [...state.agyFavorites] : [...state.generalFavorites];
1675
+ const arr = listType === 'agy' ? [...state.agyFavorites] : listType === 'codex-subagents' ? [...state.codexSubagentModels] : [...state.generalFavorites];
1616
1676
  const prev = [...arr];
1617
1677
  const [item] = arr.splice(from, 1);
1618
1678
  arr.splice(to, 0, item);
@@ -1620,6 +1680,10 @@ function reorderFavorites(from, to, listType) {
1620
1680
  state.agyFavorites = arr;
1621
1681
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1622
1682
  renderAgyList();
1683
+ } else if (listType === 'codex-subagents') {
1684
+ state.codexSubagentModels = arr;
1685
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1686
+ renderCodexSubagentList();
1623
1687
  } else {
1624
1688
  state.generalFavorites = arr;
1625
1689
  saveFavorites({ favoriteModels: state.generalFavorites });
@@ -1630,6 +1694,10 @@ function reorderFavorites(from, to, listType) {
1630
1694
  state.agyFavorites = prev;
1631
1695
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1632
1696
  renderAgyList();
1697
+ } else if (listType === 'codex-subagents') {
1698
+ state.codexSubagentModels = prev;
1699
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1700
+ renderCodexSubagentList();
1633
1701
  } else {
1634
1702
  state.generalFavorites = prev;
1635
1703
  saveFavorites({ favoriteModels: state.generalFavorites });
@@ -1719,7 +1787,7 @@ function buildFavItem(fav, index, listType) {
1719
1787
 
1720
1788
  // Keyboard reorder
1721
1789
  item.addEventListener('keydown', e => {
1722
- const arr = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1790
+ const arr = listType === 'agy' ? state.agyFavorites : listType === 'codex-subagents' ? state.codexSubagentModels : state.generalFavorites;
1723
1791
  if (e.altKey && e.key === 'ArrowUp' && index > 0) { e.preventDefault(); reorderFavorites(index, index - 1, listType); }
1724
1792
  else if (e.altKey && e.key === 'ArrowDown' && index < arr.length - 1) { e.preventDefault(); reorderFavorites(index, index + 1, listType); }
1725
1793
  });
@@ -1765,6 +1833,33 @@ function renderAgyList() {
1765
1833
  state.agyFavorites.forEach((f, i) => list.appendChild(buildFavItem(f, i, 'agy')));
1766
1834
  }
1767
1835
 
1836
+ function renderCodexSubagentList() {
1837
+ const list = document.getElementById('codex-subagent-list');
1838
+ if (!list) return;
1839
+ list.innerHTML = '';
1840
+ if (state.codexSubagentModels.length === 0) {
1841
+ list.innerHTML = '<div class="fav-empty">No Codex SubAgent configured yet. Search above to add one.</div>';
1842
+ } else {
1843
+ state.codexSubagentModels.forEach((f, i) => list.appendChild(buildFavItem(f, i, 'codex-subagents')));
1844
+ }
1845
+ updateCodexSubagentCounter();
1846
+ }
1847
+
1848
+ function updateCodexSubagentCounter() {
1849
+ const count = state.codexSubagentModels.length;
1850
+ const counter = document.getElementById('codex-subagent-slot-count');
1851
+ if (counter) counter.innerHTML = `${count}<span class="agy-slot-max">/${CODEX_SUBAGENT_MAX}</span>`;
1852
+ const pips = document.getElementById('codex-subagent-pips');
1853
+ if (pips) {
1854
+ pips.innerHTML = '';
1855
+ for (let i = 0; i < CODEX_SUBAGENT_MAX; i++) {
1856
+ const pip = document.createElement('div');
1857
+ pip.className = 'agy-pip' + (i < count ? ' filled' : '');
1858
+ pips.appendChild(pip);
1859
+ }
1860
+ }
1861
+ }
1862
+
1768
1863
  function updateAgyCounter() {
1769
1864
  const count = state.agyFavorites.length;
1770
1865
 
@@ -1801,7 +1896,7 @@ function initNav() {
1801
1896
  // Host-only sections stay in the DOM but are CSS-hidden in server admin mode.
1802
1897
  const sectionIds = isServerAdminUi()
1803
1898
  ? ['providers', 'favorites', 'server']
1804
- : ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1899
+ : ['providers', 'favorites', 'section-codex-subagents', 'antigravity', 'apps', 'server'];
1805
1900
  const navItems = document.querySelectorAll('.nav-item');
1806
1901
  const content = document.getElementById('content');
1807
1902
 
@@ -1909,6 +2004,7 @@ async function init() {
1909
2004
 
1910
2005
  await loadConfig();
1911
2006
  renderFavList();
2007
+ renderCodexSubagentList();
1912
2008
  updateGeneralCounter();
1913
2009
  if (!isServerAdminUi()) {
1914
2010
  renderAgyList();
@@ -1929,9 +2025,11 @@ async function init() {
1929
2025
  if (!isServerAdminUi()) renderApps();
1930
2026
  // Re-render favorites now that we have full provider names
1931
2027
  renderFavList();
2028
+ renderCodexSubagentList();
1932
2029
  if (!isServerAdminUi()) renderAgyList();
1933
2030
  if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1934
2031
  if (!isServerAdminUi() && state.agyFilter) buildModelResults(state.agyFilter, 'agy');
2032
+ if (!isServerAdminUi() && state.codexSubagentFilter) buildModelResults(state.codexSubagentFilter, 'codex-subagents');
1935
2033
  syncProviderModelBrowserFromHash();
1936
2034
  });
1937
2035
 
@@ -2035,6 +2133,15 @@ async function init() {
2035
2133
  buildModelResults(state.agyFilter, 'agy');
2036
2134
  });
2037
2135
 
2136
+ document.getElementById('codex-subagent-search')?.addEventListener('input', e => {
2137
+ state.codexSubagentFilter = e.target.value;
2138
+ buildModelResults(state.codexSubagentFilter, 'codex-subagents');
2139
+ });
2140
+ document.getElementById('codex-subagent-free-only')?.addEventListener('change', e => {
2141
+ state.codexSubagentFreeOnly = e.target.checked;
2142
+ buildModelResults(state.codexSubagentFilter, 'codex-subagents');
2143
+ });
2144
+
2038
2145
  document.getElementById('app-model-search')?.addEventListener('input', e => {
2039
2146
  state.appModelFilter = e.target.value;
2040
2147
  renderApps();
@@ -2095,10 +2202,10 @@ function matchedAppModels(appId) {
2095
2202
  const providerName = getProviderName(m.providerId);
2096
2203
  if (state.appFreeOnly && !isFreeModel(m)) return false;
2097
2204
  if (!q) return true;
2098
- return m.id.toLowerCase().includes(q)
2099
- || (m.name && m.name.toLowerCase().includes(q))
2100
- || providerName.toLowerCase().includes(q)
2101
- || (m.providerName && m.providerName.toLowerCase().includes(q));
2205
+ return matchesModelSearch(m.id, q)
2206
+ || (m.name && matchesModelSearch(m.name, q))
2207
+ || matchesModelSearch(providerName, q)
2208
+ || (m.providerName && matchesModelSearch(m.providerName, q));
2102
2209
  });
2103
2210
  return matched.slice(0, 80);
2104
2211
  }
@@ -2238,6 +2345,15 @@ function renderApps() {
2238
2345
  </span>
2239
2346
  </label>
2240
2347
  ` : ''}
2348
+ ${app.id === 'codex' || app.id === 'codex-app' ? `
2349
+ <label class="claude-proxy-option">
2350
+ <input type="checkbox" ${state.appWithNative[app.id] ? 'checked' : ''} onchange="setCodexNativeMode('${app.id}', this.checked)">
2351
+ <span class="claude-proxy-label">
2352
+ Load native Codex models alongside Relay models
2353
+ <span class="claude-proxy-tooltip" tabindex="0" role="img" aria-label="Keeps native Codex models available while exposing your Relay models and Codex SubAgent for this launch only." data-tooltip="Keeps native Codex models available while exposing your Relay models and Codex SubAgent for this launch only.">?</span>
2354
+ </span>
2355
+ </label>
2356
+ ` : ''}
2241
2357
  ${app.type !== 'app' ? `
2242
2358
  <div class="launch-folder-control">
2243
2359
  <label style="font-size: 12px; font-weight: 500; color: var(--color-muted);">Launch folder 📁</label>
@@ -2281,6 +2397,10 @@ function renderApps() {
2281
2397
  renderAppPathSettings();
2282
2398
  }
2283
2399
 
2400
+ function setCodexNativeMode(appId, enabled) {
2401
+ state.appWithNative[appId] = Boolean(enabled);
2402
+ }
2403
+
2284
2404
  function renderAppPathSettings() {
2285
2405
  const container = document.getElementById('app-paths-list');
2286
2406
  if (!container) return;
@@ -2323,6 +2443,9 @@ async function launchApp(appId) {
2323
2443
  if (appId === 'claude' && state.appHttpProxy[appId] && claudeHttpProxyAvailable(appId)) {
2324
2444
  body.httpProxy = true;
2325
2445
  }
2446
+ if ((appId === 'codex' || appId === 'codex-app') && state.appWithNative[appId]) {
2447
+ body.withNative = true;
2448
+ }
2326
2449
 
2327
2450
  const folder = (state.appLaunchFolders[appId] ?? '').trim();
2328
2451
  if (folder) {
@@ -82,6 +82,12 @@
82
82
  </span>
83
83
  Favorites
84
84
  </a>
85
+ <a href="#section-codex-subagents" class="nav-item" data-section="section-codex-subagents" id="nav-codex-subagents">
86
+ <span class="nav-icon">
87
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M8 12h8M12 8v8"/></svg>
88
+ </span>
89
+ Codex SubAgent
90
+ </a>
85
91
  <a href="#antigravity" class="nav-item" data-section="antigravity">
86
92
  <span class="nav-icon">
87
93
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 22 8.5 22 15.5 12 22 2 15.5 2 8.5"/></svg>
@@ -172,6 +178,32 @@
172
178
  <div id="favorites-list" class="fav-list" role="list" aria-label="General favorites" data-list="general"></div>
173
179
  </section>
174
180
 
181
+ <!-- Codex SubAgent section -->
182
+ <section id="section-codex-subagents" class="section">
183
+ <div class="section-hero">
184
+ <div class="section-eyebrow">Codex Runtime</div>
185
+ <h1 class="section-heading">Codex <span class="heading-accent">SubAgent</span></h1>
186
+ <p class="section-sub">Choose one Relay model for the Codex SubAgent. This selection starts empty and does not sync with General Favorites.</p>
187
+ </div>
188
+ <div class="agy-slot-bar">
189
+ <div class="agy-slot-label">Slots used</div>
190
+ <div class="agy-slot-pips" id="codex-subagent-pips"></div>
191
+ <div class="agy-slot-count" id="codex-subagent-slot-count">0<span class="agy-slot-max">/1</span></div>
192
+ </div>
193
+ <div class="search-row">
194
+ <div class="search-field">
195
+ <svg class="search-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
196
+ <input type="search" id="codex-subagent-search" class="search-input" placeholder="Search models to add…" aria-label="Search Codex SubAgent models">
197
+ </div>
198
+ <label class="free-only-toggle">
199
+ <input type="checkbox" id="codex-subagent-free-only">
200
+ <span>Free models only</span>
201
+ </label>
202
+ </div>
203
+ <div id="codex-subagent-results" class="model-results" hidden></div>
204
+ <div id="codex-subagent-list" class="fav-list" role="list" aria-label="Codex SubAgent" data-list="codex-subagents"></div>
205
+ </section>
206
+
175
207
  <!-- Antigravity section -->
176
208
  <section id="antigravity" class="section">
177
209
  <div class="section-hero">
@@ -1,5 +1,21 @@
1
1
  export const PROVIDER_MODEL_PAGE_SIZE = 25;
2
2
 
3
+ function normalizeSearchText(value) {
4
+ return String(value ?? '')
5
+ .toLowerCase()
6
+ .replace(/([a-z])([0-9])/g, '$1 $2')
7
+ .replace(/([0-9])([a-z])/g, '$1 $2')
8
+ .replace(/[\s\-._/:]+/g, ' ')
9
+ .trim();
10
+ }
11
+
12
+ export function matchesModelSearch(value, query) {
13
+ const tokens = normalizeSearchText(query).split(' ').filter(Boolean);
14
+ if (tokens.length === 0) return true;
15
+ const normalized = normalizeSearchText(value);
16
+ return tokens.every(token => normalized.includes(token));
17
+ }
18
+
3
19
  export function isFreeModel(model) {
4
20
  return Boolean(
5
21
  model?.isFree
@@ -10,12 +26,12 @@ export function isFreeModel(model) {
10
26
  }
11
27
 
12
28
  export function filterProviderModels(models, query, opts = {}) {
13
- const needle = query.trim().toLowerCase();
29
+ const needle = query.trim();
14
30
  const minCtx = opts.minContextWindow ?? 0;
15
31
  const freeOnly = Boolean(opts.freeOnly);
16
32
 
17
33
  return models.filter(model => {
18
- if (needle && !model.id.toLowerCase().includes(needle) && !(model.name ?? '').toLowerCase().includes(needle)) {
34
+ if (needle && !matchesModelSearch(model.id, needle) && !matchesModelSearch(model.name ?? '', needle)) {
19
35
  return false;
20
36
  }
21
37
  if (minCtx > 0 && (model.contextWindow ?? 0) < minCtx) {