synabun 2.0.5 → 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -1,4 +1,5 @@
1
1
  import { capabilityAvailability, flattenNativeCatalog, reviewTarget } from './cdx-commands.js';
2
+ import { t } from '../i18n.js';
2
3
  import { knownCodexCapabilities } from './cdx-capabilities.js';
3
4
  import { codexReadableValue, parseCodexAttachmentPayload, codexGatewayState, codexVerificationState, codexHttpsHandoff } from './cdx-protocol.js';
4
5
 
@@ -333,23 +334,27 @@ export function updateCodexGateway(tab, value) {
333
334
  accountSecurityViews.get(tab)?.();
334
335
  }
335
336
 
336
- export async function mountCodexAccountSecurity({ host, request, tab, capabilities }) {
337
+ export async function mountCodexAccountSecurity({ host, experimentalHost = host, request, tab, capabilities }) {
338
+ const tr = (key, params) => t(`codexSettings.${key}`, params);
337
339
  const view = { error: message => { error.textContent = message; } };
338
340
  const error = element('div', 'cxp-settings-hint'); error.setAttribute('role', 'alert');
339
341
  const gateway = element('div', 'cxp-settings-section');
340
342
  const verification = element('div', 'cxp-settings-section');
341
- host.append(gateway, verification, error);
343
+ host.append(gateway, error);
344
+ experimentalHost.append(verification);
342
345
  const available = name => capabilityAvailability(knownCodexCapabilities(tab) || capabilities, name);
343
346
  const renderGateway = () => {
344
347
  if (!host.isConnected || tab.closed) { accountSecurityViews.delete(tab); return; }
345
348
  gateway.replaceChildren();
346
349
  if (!available('gateway_oauth_read').supported) return;
347
350
  const value = tab.gatewayOAuth || {};
351
+ gateway.hidden = !value.required;
352
+ if (!value.required) return;
348
353
  const state = codexGatewayState(value);
349
- gateway.append(element('div', 'cxp-settings-section-title', 'Gateway OAuth'));
350
- gateway.append(element('div', 'cxp-settings-hint', `${value.providerName || value.providerId || 'Provider'} · ${state.label}`));
354
+ gateway.append(element('div', 'cxp-settings-section-title', tr('gateway')));
355
+ gateway.append(element('div', 'cxp-settings-hint', `${value.providerName || value.providerId || tr('provider')} · ${tr(`gatewayStates.${value.status || 'notReady'}`)}`));
351
356
  const action = state.pending ? 'gateway_oauth_cancel' : 'gateway_oauth_login';
352
- gateway.append(button(state.pending ? 'Cancel' : 'Sign in', async () => {
357
+ gateway.append(button(tr(state.pending ? 'cancel' : 'signIn'), async () => {
353
358
  if (!available(action).supported) throw new Error(available(action).reason);
354
359
  await request(action, {});
355
360
  // Notification is authoritative; do not overwrite a completed login with a stale optimistic state.
@@ -360,10 +365,10 @@ export async function mountCodexAccountSecurity({ host, request, tab, capabiliti
360
365
  }, view, available(action)));
361
366
  if (!available(action).supported) gateway.append(element('div', 'cxp-settings-hint', available(action).reason));
362
367
  const handoff = codexHttpsHandoff(value.authUrl);
363
- if (value.authUrl && !handoff) gateway.append(element('div', 'cxp-settings-hint', 'Authorization URL is unavailable: HTTPS is required.'));
368
+ if (value.authUrl && !handoff) gateway.append(element('div', 'cxp-settings-hint', tr('httpsRequired')));
364
369
  if (handoff && state.pending) {
365
- gateway.append(element('div', 'cxp-settings-hint', `Continue sign-in at ${handoff.host}`));
366
- gateway.append(button(`Open ${handoff.host}`, () => {
370
+ gateway.append(element('div', 'cxp-settings-hint', tr('continueAt', { host: handoff.host })));
371
+ gateway.append(button(tr('openHost', { host: handoff.host }), () => {
367
372
  window.open(handoff.url, '_blank', 'noopener,noreferrer');
368
373
  }, view));
369
374
  }
@@ -386,12 +391,15 @@ export async function mountCodexAccountSecurity({ host, request, tab, capabiliti
386
391
  const response = await request('verification_status', {});
387
392
  if (!host.isConnected || tab.closed) return;
388
393
  const state = codexVerificationState(response.verification);
389
- verification.append(element('div', 'cxp-settings-section-title', 'User verification · Experimental'));
390
- verification.append(element('div', 'cxp-settings-hint', state.label));
391
- verification.append(element('div', 'cxp-settings-hint', 'Local device credential only; backend registration is managed by the verifier.'));
394
+ const reason = response.verification?.unavailableReason;
395
+ verification.hidden = !!reason && reason !== 'credentialMissing';
396
+ if (verification.hidden) return;
397
+ verification.append(element('div', 'cxp-settings-section-title', tr('verification')));
398
+ verification.append(element('div', 'cxp-settings-hint', tr(state.enrolled ? 'credentialReady' : 'credentialMissing')));
399
+ verification.append(element('div', 'cxp-settings-hint', tr('localCredential')));
392
400
  const action = state.enrolled ? 'verification_delete' : 'verification_enroll';
393
- const control = button(state.enrolled ? 'Remove' : 'Enroll', async () => {
394
- if (state.enrolled && !window.confirm('Remove the local user verification credential?')) return;
401
+ const control = button(tr(state.enrolled ? 'remove' : 'enroll'), async () => {
402
+ if (state.enrolled && !window.confirm(tr('removeCredential'))) return;
395
403
  await request(action, {}); await readVerification();
396
404
  }, view, available(action));
397
405
  if (state.enrolled) control.style.color = 'var(--cxp-danger)';
@@ -399,7 +407,7 @@ export async function mountCodexAccountSecurity({ host, request, tab, capabiliti
399
407
  if (!available(action).supported) verification.append(element('div', 'cxp-settings-hint', available(action).reason));
400
408
  } catch (failure) {
401
409
  if (available('verification_status').supported) {
402
- verification.append(element('div', 'cxp-settings-hint', `User verification unavailable: ${failure.message}`));
410
+ verification.append(element('div', 'cxp-settings-hint', tr('error', { message: failure.message })));
403
411
  }
404
412
  }
405
413
  }
@@ -41,6 +41,8 @@ export let windowId = hostedSidepanelId || sessionStorage.getItem('cxp-window-id
41
41
  // ── Storage Keys ──
42
42
  export const STOR = {
43
43
  threadId: 'synabun-codex-panel-thread',
44
+ // The account that thread belongs to (its rollout lives in that account's CODEX_HOME).
45
+ threadAccount: 'synabun-codex-panel-thread-account',
44
46
  title: 'synabun-codex-panel-title',
45
47
  project: 'synabun-codex-panel-project',
46
48
  projectThreads: 'synabun-codex-panel-project-threads',
@@ -4,7 +4,7 @@
4
4
  import { storage } from '../storage.js';
5
5
  import { state, emit, on } from '../state.js';
6
6
  import { registerSidepanel, setSidepanelVisible, sidepanelAcceptsKeyboard, openAnotherSidepanelSession } from '../ui-sidepanel-windows.js';
7
- import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from '../cli-status.js';
7
+ import { mountCliBanner } from '../ui-cli-banner.js';
8
8
  import { nativeLoopWindowId } from '../ui-native-window-id.js';
9
9
  import { createStickyScrollController, getStickyScrollController } from '../ui-scroll-follow.js';
10
10
  import { wireFileLinks } from '../ui-file-links.js';
@@ -30,6 +30,7 @@ import {
30
30
  requestModelList, populateModelDropdown,
31
31
  setSessionLabel, renameActiveSession, promptNameNewSession, openSettingsPanel, renderSessionMenu,
32
32
  toggleAccountMenu,
33
+ mountCodexAccountMenu,
33
34
  contextMenuData, contextMenuReading, refreshContextMenuDetails,
34
35
  addImageFromFile, removeImage, renderImageStrip,
35
36
  addPathChip, removePathChip, renderPathStrip,
@@ -62,8 +63,7 @@ document.addEventListener('visibilitychange', () => {
62
63
  let _panel = null;
63
64
  let _visible = false;
64
65
  let _resizeBound = false;
65
- let _cliInstalled = null;
66
- let _cliUnsub = null;
66
+ let _cliBanner = null; // the CLI banner and send-block (ui-cli-banner.js)
67
67
 
68
68
  // ═══════════════════════════════════════════
69
69
  // Bound State (mirrors active tab — managed by cdx-tabs)
@@ -275,7 +275,6 @@ function buildPanel() {
275
275
  <button class="cxp-btn" id="cxp-slide" title="Slide panel"><svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg></button>
276
276
  </div>
277
277
  <div class="cxp-session-menu" id="cxp-session-menu"></div>
278
- <div class="cxp-account-menu" id="cxp-account-menu"></div>
279
278
  </div>
280
279
  <div class="cxp-messages-container" id="cxp-messages-container"></div>
281
280
  <div class="cxp-bottom">
@@ -406,122 +405,66 @@ function startupEmptyText() {
406
405
  }
407
406
 
408
407
  // ── CLI installation status (banner + send-block) ──
409
- let _cliInstallFailureForced = false;
410
- let _cliFailureReason = null;
411
-
412
- function ensureCliBannerEl() {
413
- if (!_panel) return null;
414
- const container = panelEl('#cxp-messages-container');
415
- if (!container || !container.parentNode) return null;
416
- let banner = container.parentNode.querySelector(':scope > .cxp-cli-banner');
417
- if (banner) { renderCliBannerContent(banner); return banner; }
418
- const url = getCliDocUrl('codex');
419
- banner = document.createElement('div');
420
- banner.className = 'cxp-cli-banner';
421
- banner.innerHTML = `
422
- <div class="cxp-cli-banner-icon">!</div>
423
- <div class="cxp-cli-banner-text">
424
- <div class="cxp-cli-banner-title"></div>
425
- <div class="cxp-cli-banner-body"></div>
426
- </div>
427
- <div class="cxp-cli-banner-actions">
428
- <a class="cxp-cli-banner-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
429
- <button class="cxp-cli-banner-recheck" type="button">Re-check</button>
430
- </div>
431
- `;
432
- container.parentNode.insertBefore(banner, container);
433
- const btn = banner.querySelector('.cxp-cli-banner-recheck');
434
- btn?.addEventListener('click', async () => {
435
- btn.disabled = true;
436
- btn.textContent = 'Checking…';
437
- try { await recheckCliStatus('codex'); }
438
- finally {
439
- if (btn.isConnected) {
440
- btn.disabled = false;
441
- btn.textContent = 'Re-check';
442
- }
443
- }
444
- });
445
- renderCliBannerContent(banner);
446
- return banner;
447
- }
448
-
449
- // Two very different situations reach this banner, and telling the user the
450
- // CLI is "not installed" when it is installed sends them off to reinstall
451
- // something that is already there. Distinguish them.
452
- function renderCliBannerContent(banner) {
453
- const titleEl = banner.querySelector('.cxp-cli-banner-title');
454
- const bodyEl = banner.querySelector('.cxp-cli-banner-body');
455
- if (!titleEl || !bodyEl) return;
456
- const label = getCliLabel('codex');
457
- bodyEl.replaceChildren();
458
-
459
- if (_cliInstalled === null) {
460
- titleEl.textContent = `${label} CLI not installed`;
461
- const cmd = getCliInstallCommand('codex');
462
- bodyEl.append('Run ');
463
- const code = document.createElement('code');
464
- code.textContent = cmd;
465
- bodyEl.append(code, ' or follow the install guide.');
466
- return;
467
- }
468
-
469
- titleEl.textContent = `${label} CLI failed to start`;
470
- bodyEl.append(`${label} v${_cliInstalled} is installed, but it could not be launched. `);
471
- if (_cliFailureReason) {
472
- const code = document.createElement('code');
473
- code.textContent = _cliFailureReason;
474
- bodyEl.append(code);
475
- } else {
476
- bodyEl.append('Check the CLI path in Settings, then re-check.');
408
+ // One controller (ui-cli-banner.js) draws the banner above the transcript while
409
+ // Codex is not usable: not installed (with Install, which runs OpenAI's own
410
+ // installer on the server), being installed, failed to install, or found but
411
+ // failed to start (with the error it gave). It toggles `cxp-cli-blocked` on the
412
+ // panel, which syncInputEnabled (cdx-tabs.js) reads to keep Send off. What it
413
+ // says is cli-install-view.js (`cliInstall.*` strings).
414
+ let _cliWasBlocked = false;
415
+
416
+ function cliBanner() {
417
+ if (!_cliBanner) {
418
+ _cliBanner = mountCliBanner({
419
+ toolKey: 'codex',
420
+ prefix: 'cxp',
421
+ panel: () => _panel,
422
+ anchor: () => panelEl('#cxp-messages-container'),
423
+ onBlockedChange: (blocked) => {
424
+ try { syncInputEnabled(); } catch {}
425
+ const usableAgain = _cliWasBlocked && !blocked;
426
+ _cliWasBlocked = blocked;
427
+ if (usableAgain) reconnectAfterCli();
428
+ },
429
+ });
477
430
  }
431
+ return _cliBanner;
478
432
  }
479
433
 
480
- function removeCliBannerEl() {
481
- if (!_panel) return;
482
- const container = panelEl('#cxp-messages-container');
483
- const banner = container?.parentNode?.querySelector(':scope > .cxp-cli-banner');
484
- banner?.remove();
434
+ // Codex was installed (or found again) after this tab's session could not start it: the tab connects again, so
435
+ // Send works without reopening the panel. A tab that is running, or one an automation owns, is left alone.
436
+ function reconnectAfterCli() {
437
+ const tab = activeTab();
438
+ if (!tab || tab.closed || tab.automationActive || tab.running) return;
439
+ if (tab.connected && tab.bootstrapped) return;
440
+ try {
441
+ if (tab.ws && tab.ws.readyState === WebSocket.OPEN) disconnectTab(tab);
442
+ connectTab(tab);
443
+ } catch (error) {
444
+ console.warn('[cdx-panel] reconnect after Codex became usable failed', error);
445
+ }
485
446
  }
486
447
 
487
448
  function refreshCliBanner() {
488
449
  if (!_panel) return;
489
- const missing = _cliInstalled === null || _cliInstallFailureForced;
490
- _panel.classList.toggle('cxp-cli-blocked', missing);
491
- if (missing) ensureCliBannerEl();
492
- else removeCliBannerEl();
450
+ cliBanner().refresh();
493
451
  try { syncInputEnabled(); } catch {}
494
452
  }
495
453
 
496
454
  export function isCdxCliInstalled() {
497
- return _cliInstalled !== null && !_cliInstallFailureForced;
455
+ return !cliBanner().blocked();
498
456
  }
499
457
 
458
+ // A session could not start Codex: "failed to start" with its words at once,
459
+ // then the server is asked again and its answer decides what stays. (The
460
+ // answer is read from the re-check itself: a spawn failure does not change
461
+ // the version, so a change-only notification would never clear the banner.)
500
462
  export function flagCdxCliInstallFailure(reason = null) {
501
- _cliInstallFailureForced = true;
502
- _cliFailureReason = reason ? String(reason).slice(0, 300) : null;
503
- refreshCliBanner();
504
- // Clear the latch from the value recheckCliStatus RETURNS. Relying on the
505
- // subscriber callback alone deadlocked: the callback only fired when the
506
- // version string changed, and a spawn failure does not change the version,
507
- // so the banner could never be dismissed — the Re-check button included.
508
- recheckCliStatus('codex')
509
- .then((info) => {
510
- if (!info?.installed) return;
511
- _cliInstallFailureForced = false;
512
- _cliFailureReason = null;
513
- refreshCliBanner();
514
- })
515
- .catch(() => {});
463
+ cliBanner().flagFailure(reason);
516
464
  }
517
465
 
518
466
  function ensureCliSubscription() {
519
- if (_cliUnsub) return;
520
- _cliUnsub = subscribeCliStatus('codex', (info) => {
521
- _cliInstalled = info?.installed || null;
522
- if (_cliInstalled) { _cliInstallFailureForced = false; _cliFailureReason = null; }
523
- refreshCliBanner();
524
- });
467
+ cliBanner();
525
468
  }
526
469
 
527
470
  // ═══════════════════════════════════════════
@@ -906,7 +849,6 @@ function wireEvents() {
906
849
  },
907
850
  onOpen: () => {
908
851
  sessionMenu?.classList.remove('open');
909
- panelEl('#cxp-account-menu')?.classList.remove('open');
910
852
  panelEl('#cxp-effort-menu')?.classList.remove('open');
911
853
  refreshContextMenuDetails();
912
854
  },
@@ -916,8 +858,8 @@ function wireEvents() {
916
858
  // After this click has reached the document, whose handler closes the menu.
917
859
  onAccount: () => setTimeout(() => toggleAccountMenu(), 0),
918
860
  });
919
- // The account menu hangs under the header, which a floating window is moved by.
920
- panelEl('#cxp-account-menu')?.addEventListener('pointerdown', (event) => event.stopPropagation());
861
+ // The header account menu (account-menu.js, shared with the Claude panel): before the cog.
862
+ mountCodexAccountMenu(panelEl('.cxp-actions'), _panel);
921
863
  const slideBtn = panelEl('#cxp-slide');
922
864
  slideBtn?.addEventListener('click', () => setVisible(false));
923
865
  const changelogBtn = panelEl('#cxp-action-changelog');
@@ -1035,7 +977,6 @@ function wireEvents() {
1035
977
  });
1036
978
  document.addEventListener('click', (event) => {
1037
979
  if (!event.target.closest('.cxp-header')) sessionMenu?.classList.remove('open');
1038
- if (!event.target.closest('#cxp-account-menu')) panelEl('#cxp-account-menu')?.classList.remove('open');
1039
980
  if (!event.target.closest('.cxp-dropdown')) {
1040
981
  _panel?.querySelectorAll('.cxp-dropdown.open').forEach((node) => node.classList.remove('open'));
1041
982
  }
@@ -0,0 +1,231 @@
1
+ import { t, getLocale } from '../i18n.js';
2
+ import { accountSummary } from '../account-view.js';
3
+ import { configChanges, configRestriction, configValue, SESSION_CONFIG_FIELDS, sessionSettings, modelServiceTiers, capabilityAvailability } from './cdx-commands.js';
4
+ import { mountCodexAccountSecurity } from './cdx-features.js';
5
+
6
+ const overrideValue = (field, value) => field === 'sandboxPolicy' && value ? ({ readOnly: 'read-only', workspaceWrite: 'workspace-write', dangerFullAccess: 'danger-full-access' })[value.type] ?? null : value;
7
+ const tr = (key, params) => t(`codexSettings.${key}`, params);
8
+ const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = text; return node; };
9
+ const metadata = [
10
+ ['model', 'model', 'models'], ['review_model', 'reviewModel', 'models'],
11
+ ['model_reasoning_effort', 'effort', 'models'], ['model_reasoning_summary', 'summary', 'models'],
12
+ ['service_tier', 'serviceTier', 'models'], ['personality', 'personality', 'models'],
13
+ ['approval_policy', 'approval', 'permissions'], ['sandbox_mode', 'sandbox', 'permissions'],
14
+ ['web_search', 'webSearch', 'tools'], ['model_provider', 'provider', 'advanced'],
15
+ ['model_verbosity', 'verbosity', 'advanced'], ['model_context_window', 'contextWindow', 'advanced'],
16
+ ['model_auto_compact_token_limit', 'autoCompact', 'advanced'],
17
+ ];
18
+ export function codexSettingsDefinitions(schema, models, cfg) {
19
+ const model = models.find(m => (m.model || m.id) === cfg.model) || models.find(m => m.isDefault) || models[0];
20
+ const modelOptions = models.map(m => ({ value: m.model || m.id, label: m.displayName || m.label || m.model || m.id }));
21
+ return metadata.flatMap(([key, label, section]) => {
22
+ const field = schema.fields?.[key]; if (!field) return [];
23
+ if (key === 'service_tier' && cfg.model_provider && cfg.model_provider !== 'openai') return [];
24
+ let options = (field.values || []).map(value => ({ value, label: tr(`values.${value}`) === `codexSettings.values.${value}` ? value : tr(`values.${value}`) }));
25
+ if (key === 'model' || key === 'review_model') options = modelOptions;
26
+ if (key === 'model_reasoning_effort') options = (model?.supportedReasoningEfforts || []).map(e => ({ value: e.reasoningEffort, label: e.reasoningEffort }));
27
+ if (key === 'service_tier') options = modelServiceTiers(model);
28
+ if (key === 'personality' && !model?.supportsPersonality) return [];
29
+ // The provider list is configured locally. Built-in OpenAI plus named providers.
30
+ if (key === 'model_provider') options = [...new Set(['openai', ...Object.keys(cfg.model_providers || {}), cfg.model_provider].filter(Boolean))].map(value => ({ value, label: value }));
31
+ if (field.type !== 'number' && !options.length) return [];
32
+ return [{ key, label: tr(label), section, type: field.type, options: [{ value: '', label: tr('default') }, ...options] }];
33
+ });
34
+ }
35
+
36
+ export async function mountCodexSettings({ host, tab, request, capabilities, account, models, normalizeConfig, manageAccount, authenticate, onSaved }) {
37
+ if (host.querySelector('.cxp-settings-overlay')) return;
38
+ const previousFocus = document.activeElement;
39
+ const overlay = el('div', 'cxp-settings-overlay');
40
+ const panel = el('div', 'cxp-settings-panel'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', 'true'); panel.setAttribute('aria-label', tr('title'));
41
+ const header = el('div', 'cxp-settings-header');
42
+ const close = el('button', 'cxp-settings-close', '×'); close.setAttribute('aria-label', tr('close'));
43
+ const dismiss = () => { overlay.remove(); if (previousFocus?.isConnected) previousFocus.focus(); };
44
+ close.onclick = dismiss; header.append(el('span', '', tr('title')), close);
45
+ const body = el('div', 'cxp-settings-body'); const sections = {};
46
+ for (const key of ['account', 'models', 'permissions', 'tools', 'advanced']) {
47
+ const group = el(key === 'advanced' ? 'details' : 'section', 'cxp-settings-section'); group.dataset.section = key;
48
+ group.append(el(key === 'advanced' ? 'summary' : 'h3', 'cxp-settings-section-title', tr(key)));
49
+ const content = el('div', 'cxp-settings-group'); group.append(content); sections[key] = content; body.append(group);
50
+ }
51
+ const result = el('div', 'cxp-settings-result'); result.setAttribute('role', 'status');
52
+ const footer = el('div', 'cxp-settings-footer');
53
+ panel.append(header, body, footer); overlay.append(panel); host.append(overlay);
54
+ const error = message => { result.textContent = tr('error', { message }); result.dataset.error = 'true'; };
55
+ overlay.onclick = event => { if (event.target === overlay) dismiss(); };
56
+ overlay.onkeydown = event => {
57
+ if (event.key === 'Escape') { event.stopPropagation(); dismiss(); }
58
+ if (event.key === 'Tab') {
59
+ const controls = [...panel.querySelectorAll('button,select,input,summary,a[href]')].filter(n => !n.disabled && n.getClientRects().length);
60
+ if (event.shiftKey && document.activeElement === controls[0]) { event.preventDefault(); controls.at(-1)?.focus(); }
61
+ else if (!event.shiftKey && document.activeElement === controls.at(-1)) { event.preventDefault(); controls[0]?.focus(); }
62
+ }
63
+ };
64
+ close.focus();
65
+ footer.append(result);
66
+ const action = (label, fn) => {
67
+ const button = el('button', 'cxp-btn cxp-btn-sm', label); button.type = 'button';
68
+ button.onclick = async () => { button.disabled = true; try { await fn(); } catch (failure) { error(failure.message); } finally { if (button.isConnected) button.disabled = false; } };
69
+ return button;
70
+ };
71
+ sections.account.append(el('div', 'cxp-settings-loading', tr('loading')));
72
+ try {
73
+ const [configData, mcpData, permissions, limits, requirements, accountData] = await Promise.allSettled([
74
+ request('config_read', { cwd: tab.project || null, threadId: tab.threadId || null }), request('mcp_status'),
75
+ request('permission_roots_list'), request('rate_limits_read'), request('config_requirements'),
76
+ request('account_read'),
77
+ ]);
78
+ if (!overlay.isConnected || tab.closed) return;
79
+ sections.account.replaceChildren();
80
+ const details = el('div', 'cxp-settings-account-details'); details.dataset.codexAccountDetails = 'true';
81
+ const currentAccount = accountData.status === 'fulfilled' ? (accountData.value?.accountState || account) : account;
82
+ details.append(el('div', 'cxp-settings-value', currentAccount ? accountSummary(currentAccount, 'codex') : tr('defaultAccount')));
83
+ const rateLimits = limits.status === 'fulfilled' ? limits.value?.rateLimits : null;
84
+ const buckets = Object.values(rateLimits?.rateLimitsByLimitId || {});
85
+ if (!buckets.length && rateLimits?.rateLimits) buckets.push(rateLimits.rateLimits);
86
+ for (const bucket of buckets) {
87
+ const percent = bucket.primary?.usedPercent;
88
+ if (!Number.isFinite(percent)) continue;
89
+ const time = bucket.primary?.resetsAt ? new Date(bucket.primary.resetsAt * 1000).toLocaleString(getLocale()) : tr('unknown');
90
+ details.append(el('div', 'cxp-settings-hint', tr('usage', { name: bucket.limitName || bucket.limitId || 'Codex', percent: Math.round(percent), time })));
91
+ }
92
+ details.append(action(tr('manage'), () => { dismiss(); manageAccount(); })); sections.account.append(details);
93
+ await mountCodexAccountSecurity({ host: sections.account, experimentalHost: sections.advanced, request, tab, capabilities });
94
+ const runtime = tab.codexRuntime || {};
95
+ sections.advanced.append(el('div', 'cxp-settings-hint cxp-settings-runtime', tr('runtime', { cli: runtime.cliVersion || tr('unknown'), sdk: runtime.sdkVersion || tr('unknown'), protocol: runtime.protocolBaseline || tr('unknown') })));
96
+ if (runtime.codexBin) sections.advanced.append(el('div', 'cxp-settings-hint', runtime.codexBin));
97
+ const mcp = el('div', ''); mcp.id = 'cxp-settings-mcp'; sections.tools.append(el('h4', 'cxp-settings-subtitle', tr('mcp')), mcp);
98
+ async function renderMcp(data) {
99
+ if (data.error) throw new Error(data.error);
100
+ mcp.replaceChildren();
101
+ for (const server of data.servers || []) {
102
+ const row = el('div', 'cxp-mcp-row');
103
+ const statusKey = server.requiresOAuth ? 'loginRequired' : server.status === 'running' ? 'running' : 'configured';
104
+ const status = el('span', 'cxp-mcp-status', tr(statusKey)); status.dataset.status = server.status;
105
+ row.append(el('span', 'cxp-mcp-name', server.name), status);
106
+ if (server.alwaysAllowed) row.append(el('span', 'cxp-mcp-status', tr('alwaysAllowed')));
107
+ row.append(
108
+ action(tr('refresh'), async () => { await request('mcp_refresh', { name: server.name }); await renderMcp(await request('mcp_status')); }));
109
+ if (server.alwaysAllowed) row.append(action(tr('revoke'), async () => {
110
+ await request('mcp_approval_set', { name: server.name, approvalMode: 'prompt' }); await renderMcp(await request('mcp_status'));
111
+ }));
112
+ if (server.requiresOAuth) row.append(action(tr('authenticate'), () => authenticate(server.name)));
113
+ mcp.append(row);
114
+ }
115
+ if (!mcp.childElementCount) mcp.append(el('div', 'cxp-settings-hint', tr('noMcp')));
116
+ }
117
+ if (mcpData.status === 'fulfilled') await renderMcp(mcpData.value); else error(mcpData.reason.message);
118
+ const roots = el('div', ''); roots.id = 'cxp-settings-permissions';
119
+ sections.permissions.append(el('h4', 'cxp-settings-subtitle', tr('folders')), roots);
120
+ async function renderRoots(data) {
121
+ if (data.error) throw new Error(data.error);
122
+ roots.replaceChildren(); const fs = data.permissions?.filesystem || {};
123
+ for (const access of ['write', 'read']) for (const path of fs[access] || []) {
124
+ const row = el('div', 'cxp-mcp-row'); const name = el('span', 'cxp-mcp-name cxp-permission-root-path', path); name.title = path;
125
+ row.append(name, el('span', 'cxp-mcp-status', tr(access)), action(tr('revoke'), async () => {
126
+ await request('permission_root_remove', { access, path }); await renderRoots(await request('permission_roots_list'));
127
+ })); roots.append(row);
128
+ }
129
+ if (!roots.childElementCount) roots.append(el('div', 'cxp-settings-hint', tr('none')));
130
+ }
131
+ if (permissions.status === 'fulfilled') await renderRoots(permissions.value); else error(permissions.reason.message);
132
+ if (configData.status !== 'fulfilled' || configData.value.error) throw new Error(configData.value?.error || configData.reason.message);
133
+ let cfg = normalizeConfig(configData.value.config).effective;
134
+ let overrides = Object.fromEntries(Object.entries(SESSION_CONFIG_FIELDS).filter(([, field]) => Object.hasOwn(configData.value.sessionOverrides || {}, field)).map(([key, field]) => [key, overrideValue(field, configData.value.sessionOverrides[field])]));
135
+ const schema = configData.value.settingsSchema;
136
+ if (!schema?.fields) throw new Error(tr('noSchema'));
137
+ const modelList = await models();
138
+ let defs = [], inputs = {}, restrictions = {};
139
+ const fields = {};
140
+ for (const key of ['models', 'permissions', 'tools', 'advanced']) { fields[key] = el('div', 'cxp-config-form'); sections[key].prepend(fields[key]); }
141
+ const scopeRow = el('label', 'cxp-config-row cxp-settings-scope'); scopeRow.append(el('span', '', tr('applyTo')));
142
+ const scope = el('select', 'cxp-config-input'); scope.id = 'cxp-settings-scope'; scope.setAttribute('aria-label', tr('applyTo'));
143
+ for (const [value, label] of [['saved', 'savedDefaults'], ['session', 'currentThread']]) { const o = el('option', '', tr(label)); o.value = value; o.disabled = value === 'session' && (!tab.threadId || !capabilityAvailability(capabilities, 'thread_settings_update').supported); scope.append(o); }
144
+ scopeRow.append(scope); body.prepend(scopeRow);
145
+ const scopeHint = el('div', 'cxp-settings-hint cxp-settings-scope-hint', tr('scopeHint')); scopeRow.after(scopeHint);
146
+ const featureHost = el('details', 'cxp-settings-features'); featureHost.append(el('summary', 'cxp-settings-subtitle', tr('features'))); sections.tools.append(featureHost);
147
+ const featureInputs = {};
148
+ for (const feature of schema.features || []) {
149
+ if (!['stable', 'beta'].includes(feature.stage) || feature.configurable === false) continue;
150
+ const row = el('label', 'cxp-config-row'); row.append(el('span', 'cxp-config-label', feature.name));
151
+ const input = el('button', 'cxp-settings-toggle'); input.type = 'button'; input.setAttribute('role', 'switch'); input.setAttribute('aria-label', feature.name); input.dataset.feature = feature.name;
152
+ const restriction = configRestriction(requirements.status === 'fulfilled' ? requirements.value : {}, `features.${feature.name}`);
153
+ input.dataset.fixed = String(typeof restriction.fixed === 'boolean');
154
+ const set = value => { input.dataset.enabled = String(value); input.setAttribute('aria-checked', String(value)); input.textContent = tr(value ? 'on' : 'off'); };
155
+ set(typeof restriction.fixed === 'boolean' ? restriction.fixed : feature.enabled);
156
+ input.onclick = () => set(input.dataset.enabled !== 'true'); row.append(input); featureHost.append(row); featureInputs[feature.name] = { input, feature, set };
157
+ }
158
+ const save = action(tr('saveDefaults'), async () => {
159
+ result.textContent = tr('saving'); delete result.dataset.error;
160
+ const current = scope.value === 'session' ? { ...cfg, ...overrides } : cfg;
161
+ const patch = configChanges(defs, inputs, current);
162
+ let featureEdits = {};
163
+ if (scope.value === 'session') {
164
+ if (tab.running) throw new Error(tr('busy'));
165
+ if (Object.keys(patch).length) await request('thread_settings_update', { threadId: tab.threadId, cwd: tab.project || null, ...sessionSettings(patch) });
166
+ } else {
167
+ const enablement = Object.fromEntries(Object.values(featureInputs).filter(({ input, feature }) => !input.disabled && (input.dataset.enabled === 'true') !== feature.enabled).map(({ input, feature }) => [feature.name, input.dataset.enabled === 'true']));
168
+ featureEdits = enablement;
169
+ const config = { ...patch, ...Object.fromEntries(Object.entries(enablement).map(([key, value]) => [`features.${key}`, value])) };
170
+ if (Object.keys(config).length) await request('config_write', { config, cwd: tab.project || null });
171
+ }
172
+ // Always re-read the runtime. Never report success from an optimistic UI copy.
173
+ const read = await request('config_read', { cwd: tab.project || null, threadId: tab.threadId || null });
174
+ if (read.error) throw new Error(read.error);
175
+ cfg = normalizeConfig(read.config).effective;
176
+ overrides = Object.fromEntries(Object.entries(SESSION_CONFIG_FIELDS).filter(([, field]) => Object.hasOwn(read.sessionOverrides || {}, field)).map(([key, field]) => [key, overrideValue(field, read.sessionOverrides[field])]));
177
+ for (const { input, feature, set } of Object.values(featureInputs)) {
178
+ const actual = read.settingsSchema?.features?.find(f => f.name === feature.name);
179
+ if (actual) { feature.enabled = actual.enabled; set(actual.enabled); }
180
+ }
181
+ renderFields(); onSaved({ scope: scope.value, patch, overrides });
182
+ const effective = scope.value === 'session' ? { ...cfg, ...overrides } : cfg;
183
+ const unapplied = Object.entries(patch).filter(([key, value]) => JSON.stringify(configValue(effective, key) ?? null) !== JSON.stringify(value));
184
+ for (const [key, value] of Object.entries(featureEdits)) if (featureInputs[key]?.feature.enabled !== value) unapplied.push([key]);
185
+ if (unapplied.length) throw new Error(tr('notApplied', { keys: unapplied.map(([key]) => key).join(', ') }));
186
+ result.textContent = tr('saved');
187
+ }); save.classList.add('cxp-config-save'); footer.prepend(save);
188
+ function renderFields(selection) {
189
+ const visible = scope.value === 'session' ? { ...cfg, ...overrides } : cfg;
190
+ const values = selection || visible;
191
+ defs = codexSettingsDefinitions(schema, modelList, values); inputs = {}; restrictions = {};
192
+ for (const node of Object.values(fields)) node.replaceChildren();
193
+ for (const def of defs) {
194
+ if (scope.value === 'session' && !SESSION_CONFIG_FIELDS[def.key]) continue;
195
+ const row = el('div', 'cxp-config-row'); const label = el('label', 'cxp-config-label', def.label);
196
+ const input = el(def.type === 'number' ? 'input' : 'select', 'cxp-config-input');
197
+ input.id = `cxp-setting-${def.key}`; input.dataset.configKey = def.key; label.htmlFor = input.id;
198
+ const value = configValue(values, def.key);
199
+ if (def.type === 'number') { input.type = 'number'; input.min = '1'; input.step = '1'; input.placeholder = tr('default'); }
200
+ else {
201
+ for (const option of def.options) { const o = el('option', '', option.label); o.value = option.value; input.append(o); }
202
+ if (value != null && !def.options.some(o => o.value === String(value))) {
203
+ const o = el('option', '', tr('configured', { value: typeof value === 'object' ? JSON.stringify(value) : value })); o.value = typeof value === 'object' ? JSON.stringify(value) : String(value); input.append(o); input.dataset.originalValue = JSON.stringify(value);
204
+ }
205
+ }
206
+ input.value = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
207
+ const restriction = configRestriction(requirements.status === 'fulfilled' ? requirements.value : {}, def.key);
208
+ restrictions[def.key] = restriction;
209
+ input.disabled = Object.hasOwn(restriction, 'fixed');
210
+ for (const o of input.options || []) if (o.value && restriction.allowed && !restriction.allowed.some(v => JSON.stringify(v) === JSON.stringify(o.value))) o.disabled = true;
211
+ const layer = normalizeConfig(configData.value.config).layers[def.key];
212
+ if (layer || restriction.reason) input.title = tr(restriction.reason ? 'managed' : 'source', { layer });
213
+ inputs[def.key] = input; row.append(label, input);
214
+ if (def.type === 'number') row.append(el('span', 'cxp-settings-unit', tr('tokens')));
215
+ fields[def.section].append(row);
216
+ }
217
+ const modelInput = inputs.model;
218
+ if (modelInput) modelInput.onchange = () => {
219
+ const draft = { ...values };
220
+ for (const def of defs) if (inputs[def.key]) draft[def.key] = inputs[def.key].value || null;
221
+ draft.model_reasoning_effort = null; draft.service_tier = null; renderFields(draft);
222
+ };
223
+ for (const { input } of Object.values(featureInputs)) input.disabled = scope.value === 'session' || input.dataset.fixed === 'true';
224
+ featureHost.hidden = scope.value === 'session';
225
+ for (const key of ['models', 'permissions', 'tools', 'advanced']) sections[key].closest('.cxp-settings-section').hidden = scope.value === 'session' && !fields[key].childElementCount;
226
+ save.textContent = tr(scope.value === 'session' ? 'applyThread' : 'saveDefaults');
227
+ }
228
+ scope.onchange = () => renderFields(); renderFields();
229
+ } catch (failure) { error(failure.message); }
230
+ return { overlay, sections };
231
+ }