@jacobbd/relay-ai 0.7.6 → 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.
@@ -7,7 +7,7 @@ import {
7
7
  listAddableTemplates,
8
8
  listSupportedTemplates,
9
9
  listVisibleOAuthTemplates
10
- } from "./chunk-HXGZ4CTV.js";
10
+ } from "./chunk-Q2FTCICO.js";
11
11
  init_provider_templates();
12
12
  export {
13
13
  PROVIDER_TEMPLATES,
@@ -17,4 +17,4 @@ export {
17
17
  listSupportedTemplates,
18
18
  listVisibleOAuthTemplates
19
19
  };
20
- //# sourceMappingURL=provider-templates-4H3C4DRL.js.map
20
+ //# sourceMappingURL=provider-templates-XKNRKAQU.js.map
@@ -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
  });
@@ -1059,9 +1087,112 @@ function buildOAuthTemplateBodyContent(template) {
1059
1087
  return content;
1060
1088
  }
1061
1089
 
1090
+ function buildClinePassBodyContent(template, card, provider) {
1091
+ const content = document.createElement('div');
1092
+ content.className = 'provider-body-content';
1093
+
1094
+ const note = document.createElement('div');
1095
+ note.className = 'oauth-device-note';
1096
+ note.textContent = provider
1097
+ ? 'Choose API key or ClinePass account sign-in. Switching methods replaces the stored credential safely.'
1098
+ : 'Choose an API key or sign in with a one-time device code.';
1099
+ content.appendChild(note);
1100
+
1101
+ const apiLabel = document.createElement('div');
1102
+ apiLabel.className = 'oauth-device-note';
1103
+ apiLabel.textContent = 'API key';
1104
+ apiLabel.style.marginTop = '10px';
1105
+ content.appendChild(apiLabel);
1106
+
1107
+ const keyRow = document.createElement('div');
1108
+ keyRow.className = 'key-row';
1109
+ const input = document.createElement('input');
1110
+ input.type = 'password';
1111
+ input.className = 'key-input';
1112
+ input.placeholder = 'Paste ClinePass API key…';
1113
+ input.autocomplete = 'off';
1114
+ const addBtn = document.createElement('button');
1115
+ addBtn.className = 'btn btn-primary';
1116
+ addBtn.textContent = provider ? 'Switch to API key' : 'Add with API key';
1117
+ keyRow.append(input, addBtn);
1118
+ content.appendChild(keyRow);
1119
+
1120
+ const apiFeedback = document.createElement('div');
1121
+ apiFeedback.className = 'key-feedback';
1122
+ content.appendChild(apiFeedback);
1123
+
1124
+ addBtn.addEventListener('click', async () => {
1125
+ const key = input.value.trim();
1126
+ if (!key) {
1127
+ apiFeedback.textContent = 'Enter an API key first.';
1128
+ apiFeedback.className = 'key-feedback error';
1129
+ return;
1130
+ }
1131
+ addBtn.disabled = true;
1132
+ apiFeedback.textContent = 'Validating key and fetching models…';
1133
+ apiFeedback.className = 'key-feedback muted';
1134
+ const result = await api('POST', '/api/providers/add', {
1135
+ templateId: 'cline-pass',
1136
+ key,
1137
+ replaceExisting: Boolean(provider),
1138
+ });
1139
+ addBtn.disabled = false;
1140
+ if (result.ok) {
1141
+ apiFeedback.textContent = `✓ ClinePass API key saved · ${result.count} models available`;
1142
+ apiFeedback.className = 'key-feedback success';
1143
+ state.modelsLoaded = false;
1144
+ await loadTemplates();
1145
+ await initModels();
1146
+ renderProviders();
1147
+ showToast('ClinePass API key saved');
1148
+ } else {
1149
+ apiFeedback.textContent = result.error ?? 'Failed to save ClinePass API key';
1150
+ if (result.hint) apiFeedback.textContent += ` — ${result.hint}`;
1151
+ apiFeedback.className = 'key-feedback error';
1152
+ }
1153
+ });
1154
+
1155
+ const oauthLabel = document.createElement('div');
1156
+ oauthLabel.className = 'oauth-device-note';
1157
+ oauthLabel.textContent = 'ClinePass account';
1158
+ oauthLabel.style.marginTop = '12px';
1159
+ content.appendChild(oauthLabel);
1160
+
1161
+ const oauthRow = document.createElement('div');
1162
+ oauthRow.className = 'key-row';
1163
+ const signInBtn = document.createElement('button');
1164
+ signInBtn.className = 'btn btn-ghost';
1165
+ signInBtn.textContent = provider?.authType === 'oauth' ? 'Re-authenticate with ClinePass' : 'Sign in with ClinePass';
1166
+ oauthRow.appendChild(signInBtn);
1167
+ content.appendChild(oauthRow);
1168
+
1169
+ const oauthFeedback = document.createElement('div');
1170
+ oauthFeedback.className = 'key-feedback';
1171
+ content.appendChild(oauthFeedback);
1172
+ signInBtn.addEventListener('click', () => beginDeviceOAuthFlow({
1173
+ providerId: 'cline-pass',
1174
+ signInButton: signInBtn,
1175
+ feedback: oauthFeedback,
1176
+ onDone: async () => {
1177
+ showToast('ClinePass account connected');
1178
+ state.modelsLoaded = false;
1179
+ await loadTemplates();
1180
+ await initModels();
1181
+ renderProviders();
1182
+ },
1183
+ }));
1184
+
1185
+ if (provider) content.appendChild(buildDeleteProviderRow(provider));
1186
+ return content;
1187
+ }
1188
+
1062
1189
  function buildTemplateBodyContent(template, card) {
1063
1190
  const isCustom = template.id === '__custom_openai__' || template.id === '__custom_anthropic__';
1064
1191
  if (isCustom) return buildCustomEndpointBodyContent(template, card);
1192
+ if (template.id === 'cline-pass'
1193
+ || (template.authMethods?.includes('api') && template.authMethods?.includes('oauth'))) {
1194
+ return buildClinePassBodyContent(template, card);
1195
+ }
1065
1196
  if (template.authType === 'oauth') return buildOAuthTemplateBodyContent(template, card);
1066
1197
 
1067
1198
  const content = document.createElement('div');
@@ -1162,6 +1293,9 @@ function buildTemplateBodyContent(template, card) {
1162
1293
  }
1163
1294
 
1164
1295
  function buildProviderBodyContent(provider) {
1296
+ if (provider.id === 'cline-pass') {
1297
+ return buildClinePassBodyContent({ id: 'cline-pass', name: 'ClinePass' }, null, provider);
1298
+ }
1165
1299
  if (provider.authType === 'oauth') return buildOAuthProviderBodyContent(provider);
1166
1300
 
1167
1301
  const content = document.createElement('div');
@@ -1192,33 +1326,34 @@ function buildProviderBodyContent(provider) {
1192
1326
  content.appendChild(keyRow);
1193
1327
  content.appendChild(feedback);
1194
1328
 
1195
- async function doSave(key) {
1196
- if (!key.trim()) return;
1197
- const result = await saveKey(provider.id, key);
1198
- if (result.ok) {
1199
- feedback.textContent = '✓ Key saved to keychain';
1200
- feedback.className = 'key-feedback success';
1201
- provider.hasKey = true;
1202
- const chip = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .status-chip`);
1203
- if (chip) { chip.className = 'status-chip has-key'; chip.textContent = 'Key stored'; }
1204
- } else {
1205
- feedback.textContent = result.error ?? 'Failed to save key';
1206
- feedback.className = 'key-feedback error';
1207
- }
1208
- setTimeout(() => { feedback.textContent = ''; feedback.className = 'key-feedback'; }, 3500);
1209
- }
1210
-
1211
- input.addEventListener('blur', () => { if (input.value) doSave(input.value); });
1212
-
1213
1329
  testBtn.addEventListener('click', async () => {
1214
- if (input.value) await doSave(input.value);
1330
+ const enteredKey = input.value.trim();
1215
1331
  feedback.textContent = 'Refreshing…';
1216
1332
  feedback.className = 'key-feedback muted';
1217
1333
  testBtn.disabled = true;
1218
- const result = await refreshProvider(provider.id);
1334
+ const result = await refreshProvider(provider.id, enteredKey || undefined);
1219
1335
  testBtn.disabled = false;
1220
1336
  if (result.ok) {
1221
- 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}`;
1222
1357
  feedback.className = 'key-feedback success';
1223
1358
  const countEl = document.querySelector(`[data-id="${CSS.escape(provider.id)}"] .provider-models-count`);
1224
1359
  if (countEl) countEl.textContent = `${result.count} models`;
@@ -1374,11 +1509,14 @@ function buildOAuthProviderBodyContent(provider) {
1374
1509
  // ─── Model search results ─────────────────────────────────────────────────────
1375
1510
 
1376
1511
  function buildModelResults(filter, listType) {
1377
- const containerId = listType === 'agy' ? 'agy-results' : 'model-results';
1512
+ const containerId = listType === 'agy' ? 'agy-results' : listType === 'codex-subagents' ? 'codex-subagent-results' : 'model-results';
1378
1513
  const container = document.getElementById(containerId);
1379
- const currentFavs = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1380
- const atCapacity = listType === 'agy' && currentFavs.length >= AGY_MAX;
1381
- 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;
1382
1520
 
1383
1521
  if (!filter) { container.hidden = true; return; }
1384
1522
  container.hidden = false;
@@ -1397,13 +1535,22 @@ function buildModelResults(filter, listType) {
1397
1535
  return;
1398
1536
  }
1399
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
+
1400
1544
  const q = filter.trim().toLowerCase();
1401
- 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));
1402
1549
  const matched = base.filter(m =>
1403
1550
  !q ||
1404
- m.id.toLowerCase().includes(q) ||
1405
- (m.name && m.name.toLowerCase().includes(q)) ||
1406
- m.providerName.toLowerCase().includes(q)
1551
+ matchesModelSearch(m.id, q) ||
1552
+ (m.name && matchesModelSearch(m.name, q)) ||
1553
+ matchesModelSearch(m.providerName, q)
1407
1554
  ).slice(0, freeOnly && !q ? 80 : 40);
1408
1555
 
1409
1556
  if (matched.length === 0) {
@@ -1448,7 +1595,7 @@ function buildModelResults(filter, listType) {
1448
1595
  addBtn.className = 'btn-add' + (isFav ? ' already-added' : '');
1449
1596
  addBtn.textContent = isFav ? '✓' : '+';
1450
1597
  addBtn.disabled = isFav || (!isFav && atCapacity);
1451
- 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');
1452
1599
  if (!isFav && !atCapacity) {
1453
1600
  addBtn.addEventListener('click', () => {
1454
1601
  addToFavorites({ providerId: m.providerId, modelId: m.id }, listType);
@@ -1470,11 +1617,20 @@ function buildModelResults(filter, listType) {
1470
1617
  function addToFavorites(fav, listType) {
1471
1618
  if (listType === 'agy') {
1472
1619
  if (state.agyFavorites.length >= AGY_MAX) return;
1620
+ if (state.agyFavorites.some(item => item.providerId === fav.providerId && item.modelId === fav.modelId)) return;
1473
1621
  state.agyFavorites = [...state.agyFavorites, fav];
1474
1622
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1475
1623
  renderAgyList();
1476
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();
1477
1632
  } else {
1633
+ if (state.generalFavorites.some(item => item.providerId === fav.providerId && item.modelId === fav.modelId)) return;
1478
1634
  state.generalFavorites = [...state.generalFavorites, fav];
1479
1635
  saveFavorites({ favoriteModels: state.generalFavorites });
1480
1636
  renderFavList();
@@ -1492,6 +1648,16 @@ function removeFromFavorites(index, listType) {
1492
1648
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1493
1649
  renderAgyList(); updateAgyCounter();
1494
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
+ });
1495
1661
  } else {
1496
1662
  const prev = [...state.generalFavorites];
1497
1663
  state.generalFavorites = state.generalFavorites.filter((_, i) => i !== index);
@@ -1506,7 +1672,7 @@ function removeFromFavorites(index, listType) {
1506
1672
  }
1507
1673
 
1508
1674
  function reorderFavorites(from, to, listType) {
1509
- const arr = listType === 'agy' ? [...state.agyFavorites] : [...state.generalFavorites];
1675
+ const arr = listType === 'agy' ? [...state.agyFavorites] : listType === 'codex-subagents' ? [...state.codexSubagentModels] : [...state.generalFavorites];
1510
1676
  const prev = [...arr];
1511
1677
  const [item] = arr.splice(from, 1);
1512
1678
  arr.splice(to, 0, item);
@@ -1514,6 +1680,10 @@ function reorderFavorites(from, to, listType) {
1514
1680
  state.agyFavorites = arr;
1515
1681
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1516
1682
  renderAgyList();
1683
+ } else if (listType === 'codex-subagents') {
1684
+ state.codexSubagentModels = arr;
1685
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1686
+ renderCodexSubagentList();
1517
1687
  } else {
1518
1688
  state.generalFavorites = arr;
1519
1689
  saveFavorites({ favoriteModels: state.generalFavorites });
@@ -1524,6 +1694,10 @@ function reorderFavorites(from, to, listType) {
1524
1694
  state.agyFavorites = prev;
1525
1695
  saveFavorites({ antigravityCliFavoriteModels: state.agyFavorites });
1526
1696
  renderAgyList();
1697
+ } else if (listType === 'codex-subagents') {
1698
+ state.codexSubagentModels = prev;
1699
+ saveFavorites({ codexSubagentModels: state.codexSubagentModels });
1700
+ renderCodexSubagentList();
1527
1701
  } else {
1528
1702
  state.generalFavorites = prev;
1529
1703
  saveFavorites({ favoriteModels: state.generalFavorites });
@@ -1613,7 +1787,7 @@ function buildFavItem(fav, index, listType) {
1613
1787
 
1614
1788
  // Keyboard reorder
1615
1789
  item.addEventListener('keydown', e => {
1616
- const arr = listType === 'agy' ? state.agyFavorites : state.generalFavorites;
1790
+ const arr = listType === 'agy' ? state.agyFavorites : listType === 'codex-subagents' ? state.codexSubagentModels : state.generalFavorites;
1617
1791
  if (e.altKey && e.key === 'ArrowUp' && index > 0) { e.preventDefault(); reorderFavorites(index, index - 1, listType); }
1618
1792
  else if (e.altKey && e.key === 'ArrowDown' && index < arr.length - 1) { e.preventDefault(); reorderFavorites(index, index + 1, listType); }
1619
1793
  });
@@ -1659,6 +1833,33 @@ function renderAgyList() {
1659
1833
  state.agyFavorites.forEach((f, i) => list.appendChild(buildFavItem(f, i, 'agy')));
1660
1834
  }
1661
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
+
1662
1863
  function updateAgyCounter() {
1663
1864
  const count = state.agyFavorites.length;
1664
1865
 
@@ -1695,7 +1896,7 @@ function initNav() {
1695
1896
  // Host-only sections stay in the DOM but are CSS-hidden in server admin mode.
1696
1897
  const sectionIds = isServerAdminUi()
1697
1898
  ? ['providers', 'favorites', 'server']
1698
- : ['providers', 'favorites', 'antigravity', 'apps', 'server'];
1899
+ : ['providers', 'favorites', 'section-codex-subagents', 'antigravity', 'apps', 'server'];
1699
1900
  const navItems = document.querySelectorAll('.nav-item');
1700
1901
  const content = document.getElementById('content');
1701
1902
 
@@ -1803,6 +2004,7 @@ async function init() {
1803
2004
 
1804
2005
  await loadConfig();
1805
2006
  renderFavList();
2007
+ renderCodexSubagentList();
1806
2008
  updateGeneralCounter();
1807
2009
  if (!isServerAdminUi()) {
1808
2010
  renderAgyList();
@@ -1823,9 +2025,11 @@ async function init() {
1823
2025
  if (!isServerAdminUi()) renderApps();
1824
2026
  // Re-render favorites now that we have full provider names
1825
2027
  renderFavList();
2028
+ renderCodexSubagentList();
1826
2029
  if (!isServerAdminUi()) renderAgyList();
1827
2030
  if (state.modelFilter) buildModelResults(state.modelFilter, 'general');
1828
2031
  if (!isServerAdminUi() && state.agyFilter) buildModelResults(state.agyFilter, 'agy');
2032
+ if (!isServerAdminUi() && state.codexSubagentFilter) buildModelResults(state.codexSubagentFilter, 'codex-subagents');
1829
2033
  syncProviderModelBrowserFromHash();
1830
2034
  });
1831
2035
 
@@ -1929,6 +2133,15 @@ async function init() {
1929
2133
  buildModelResults(state.agyFilter, 'agy');
1930
2134
  });
1931
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
+
1932
2145
  document.getElementById('app-model-search')?.addEventListener('input', e => {
1933
2146
  state.appModelFilter = e.target.value;
1934
2147
  renderApps();
@@ -1989,10 +2202,10 @@ function matchedAppModels(appId) {
1989
2202
  const providerName = getProviderName(m.providerId);
1990
2203
  if (state.appFreeOnly && !isFreeModel(m)) return false;
1991
2204
  if (!q) return true;
1992
- return m.id.toLowerCase().includes(q)
1993
- || (m.name && m.name.toLowerCase().includes(q))
1994
- || providerName.toLowerCase().includes(q)
1995
- || (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));
1996
2209
  });
1997
2210
  return matched.slice(0, 80);
1998
2211
  }
@@ -2132,6 +2345,15 @@ function renderApps() {
2132
2345
  </span>
2133
2346
  </label>
2134
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
+ ` : ''}
2135
2357
  ${app.type !== 'app' ? `
2136
2358
  <div class="launch-folder-control">
2137
2359
  <label style="font-size: 12px; font-weight: 500; color: var(--color-muted);">Launch folder 📁</label>
@@ -2175,6 +2397,10 @@ function renderApps() {
2175
2397
  renderAppPathSettings();
2176
2398
  }
2177
2399
 
2400
+ function setCodexNativeMode(appId, enabled) {
2401
+ state.appWithNative[appId] = Boolean(enabled);
2402
+ }
2403
+
2178
2404
  function renderAppPathSettings() {
2179
2405
  const container = document.getElementById('app-paths-list');
2180
2406
  if (!container) return;
@@ -2217,6 +2443,9 @@ async function launchApp(appId) {
2217
2443
  if (appId === 'claude' && state.appHttpProxy[appId] && claudeHttpProxyAvailable(appId)) {
2218
2444
  body.httpProxy = true;
2219
2445
  }
2446
+ if ((appId === 'codex' || appId === 'codex-app') && state.appWithNative[appId]) {
2447
+ body.withNative = true;
2448
+ }
2220
2449
 
2221
2450
  const folder = (state.appLaunchFolders[appId] ?? '').trim();
2222
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) {