diff --git a/CHANGELOG.md b/CHANGELOG.md
index 909b171a..caf237ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -509,6 +509,8 @@
### Added
+- **Extensions can register a native "Configure" entry point under Settings → Extensions → Installed.** For complex editors that don't fit scalar `settings_schema` fields, a boot-trusted extension can call `ext.settings.registerConfigure(handler)` on the E0 handle to add one native Configure button for its current effective-enabled Installed row, while keeping the editor UI, validation, state, and persistence fully extension-owned. Registration is trust-gated and quarantine-aware, the first handler per extension wins (duplicates return `null`), and it returns an idempotent unregister; the button never appears in Diagnostics and is removed immediately on disable/uninstall. Thanks @franksong2702. (#7111)
+
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
### Performance
diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index 7d7c8f0b..3b74e954 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -300,6 +300,48 @@ sandbox, isolation mechanism, capability or permission system, or security
boundary. Extensions still execute with the full WebUI session authority
described above.
+### Custom Configure editors
+
+Extensions with structured configuration that does not fit scalar
+`settings_schema` fields can register one custom editor entry point on their
+scoped E0 settings handle:
+
+```js
+const ext = window.hermesExt?.register?.("dictionary-manager");
+const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
+ openDictionaryManager({ opener, restoreFocus });
+});
+```
+
+`registerConfigure(handler)` is available only through a valid boot-trusted E0
+handle. It does not require `settings_schema` or extension-owned storage. The
+first handler registered for an extension wins; duplicates return `null` and do
+not replace it. A successful registration returns an idempotent unregister
+function.
+
+Core shows one **Configure** button only for the extension's current
+effective-enabled row under **Settings → Extensions → Installed**. Diagnostics
+never shows the button. Late registration updates an already-mounted Installed
+row. Disable or uninstall status removes the entry point immediately; uninstall
+followed by a same-ID reinstall cannot revive the old page-local handler until a
+full WebUI reload injects and registers the new extension script.
+
+Core enters a pending state before invoking the handler and passes the activated
+button as `opener` plus a one-shot `restoreFocus()` callback. The extension owns
+its dialog, validation, persistence, and close lifecycle. It must either call
+`restoreFocus()` when its UI closes or return a thenable whose settlement means
+the Configure UI is closed. Both paths end pending and converge on Core's
+one-shot focus restoration. A synchronous non-thenable handler remains disabled
+while unsettled. If it never calls `restoreFocus()`, it remains disabled until reload.
+Core does not guess dialog lifetime with a timeout.
+
+Synchronous throws, thenable-access failures, and asynchronous rejections are
+isolated, logged with the extension ID, and surfaced as a generic failure. Focus
+returns to the connected opener when possible, otherwise to the current visible
+Configure button or the Installed tab without forcing navigation. The hook does
+not let an extension return DOM for Core to render, add a backend settings route,
+or change the trusted same-origin extension model.
+
### Turn lifecycle events
A registered extension can react when a session turn observed by the current page
diff --git a/static/extension_settings.js b/static/extension_settings.js
index cd49f3d5..0d9cced8 100644
--- a/static/extension_settings.js
+++ b/static/extension_settings.js
@@ -11,7 +11,12 @@
const registrations=new Map();
const turnLifecycleListeners=new Map();
const turnLifecycleStates=new Map();
+ const configureRegistrations=new Map();
+ const configureQuarantinedIds=new Set();
+ const configureChangeListeners=new Set();
+ const currentExtensionStatus=new Map();
let trustedSeeded=false;
+ let extensionStatusSeeded=false;
function extensionId(value){
return String(value||'').trim();
@@ -116,6 +121,7 @@
name:text(entry&&entry.name,id),
storage_owned:storageOwned,
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
+ effective_enabled:!(entry&&entry.effective_enabled===false),
});
}
return entries;
@@ -135,6 +141,20 @@
}
trustedSeeded=true;
}
+ const previousStatus=new Map(currentExtensionStatus);
+ const nextIds=new Set(entries.map(entry=>entry.id));
+ if(extensionStatusSeeded){
+ for(const id of previousStatus.keys()){
+ if(nextIds.has(id)) continue;
+ configureQuarantinedIds.add(id);
+ notifyConfigureChange(id,'quarantine');
+ }
+ }
+ currentExtensionStatus.clear();
+ for(const entry of entries){
+ currentExtensionStatus.set(entry.id,{effective_enabled:entry.effective_enabled===true});
+ }
+ extensionStatusSeeded=true;
schemas.clear();
for(const entry of entries){
const trusted=trustedExtensions.get(entry.id);
@@ -146,6 +166,14 @@
settings_schema:trusted.storage_owned===true?trusted.settings_schema:[],
});
}
+ for(const [id,record] of configureRegistrations){
+ if(!record||!record.active) continue;
+ const before=previousStatus.get(id);
+ const after=currentExtensionStatus.get(id);
+ if(!before||!after||before.effective_enabled!==after.effective_enabled){
+ notifyConfigureChange(id,'status');
+ }
+ }
}
function safeReadState(key){
@@ -233,7 +261,7 @@
return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length);
}
- function settingsAccessor(clean,meta,isTrusted){
+ function settingsAccessor(clean,meta,isTrusted,allowConfigureRegistration){
const schema=supportsSettings(meta)?meta.settings_schema:[];
const key=settingsKey(clean);
function current(){
@@ -249,7 +277,7 @@
const saved=safeWrite(key,overridesFromValues(schema,checked.values));
return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}};
}
- return {
+ const accessor={
extensionId:clean,
trusted:isTrusted,
storageOwned:!!meta.storage_owned,
@@ -278,6 +306,10 @@
return true;
},
};
+ if(allowConfigureRegistration===true){
+ accessor.registerConfigure=handler=>registerConfigureHandler(clean,handler);
+ }
+ return accessor;
}
function settingsForExtension(id){
@@ -350,6 +382,165 @@
});
}
+ function notifyConfigureChange(id,reason){
+ const change=Object.freeze({id,reason});
+ for(const listener of [...configureChangeListeners]){
+ try{
+ listener(change);
+ }catch(error){
+ if(typeof console!=='undefined'&&typeof console.error==='function'){
+ try{console.error('[Hermes extensions] Configure change listener failed:',error);}catch(_loggingError){}
+ }
+ }
+ }
+ }
+
+ function onConfigureChange(listener){
+ if(typeof listener!=='function') return null;
+ configureChangeListeners.add(listener);
+ let active=true;
+ return function unsubscribe(){
+ if(!active) return false;
+ active=false;
+ configureChangeListeners.delete(listener);
+ return true;
+ };
+ }
+
+ function registerConfigureHandler(clean,handler){
+ if(typeof handler!=='function'||!trustedExtensions.has(clean)||configureQuarantinedIds.has(clean)) return null;
+ if(configureRegistrations.has(clean)) return null;
+ const record={handler,pending:false,active:true};
+ configureRegistrations.set(clean,record);
+ notifyConfigureChange(clean,'registration');
+ let active=true;
+ return function unregister(){
+ if(!active) return false;
+ active=false;
+ record.active=false;
+ if(configureRegistrations.get(clean)===record) configureRegistrations.delete(clean);
+ notifyConfigureChange(clean,'registration');
+ return true;
+ };
+ }
+
+ function configureStateForExtension(id){
+ const clean=extensionId(id);
+ const record=configureRegistrations.get(clean);
+ const status=currentExtensionStatus.get(clean);
+ const available=!!(
+ clean&&record&&record.active&&!configureQuarantinedIds.has(clean)
+ &&status&&status.effective_enabled===true
+ );
+ return {available,pending:available&&record.pending===true};
+ }
+
+ function focusableConfigureTarget(node){
+ if(!node||typeof node.focus!=='function'||node.isConnected===false||node.hidden===true||node.disabled===true) return false;
+ if(typeof node.getAttribute==='function'&&node.getAttribute('aria-disabled')==='true') return false;
+ if(typeof node.closest==='function'&&node.closest('[hidden]')) return false;
+ return true;
+ }
+
+ function focusConfigureTarget(clean,opener){
+ const candidates=[];
+ if(focusableConfigureTarget(opener)) candidates.push(opener);
+ if(typeof document!=='undefined'&&document){
+ if(typeof document.querySelectorAll==='function'){
+ const buttons=document.querySelectorAll('[data-extension-configure-id]');
+ for(const button of buttons){
+ if(button&&button.dataset&&button.dataset.extensionConfigureId===clean&&focusableConfigureTarget(button)){
+ candidates.push(button);
+ break;
+ }
+ }
+ }
+ if(typeof document.querySelector==='function'){
+ const installedTab=document.querySelector('[data-extensions-tab="installed"]');
+ if(focusableConfigureTarget(installedTab)) candidates.push(installedTab);
+ }
+ }
+ for(const candidate of candidates){
+ try{
+ candidate.focus({preventScroll:true});
+ return true;
+ }catch(_focusOptionsError){
+ try{
+ candidate.focus();
+ return true;
+ }catch(_focusError){}
+ }
+ }
+ return false;
+ }
+
+ function reportConfigureFailure(clean,error,onError){
+ if(typeof console!=='undefined'&&typeof console.error==='function'){
+ try{console.error(`[Hermes extensions] ${clean} Configure handler failed:`,error);}catch(_loggingError){}
+ }
+ if(typeof onError==='function'){
+ try{onError(error);}catch(callbackError){
+ if(typeof console!=='undefined'&&typeof console.error==='function'){
+ try{console.error(`[Hermes extensions] ${clean} Configure failure reporter failed:`,callbackError);}catch(_loggingError){}
+ }
+ }
+ }
+ }
+
+ function invokeConfigure(id,options){
+ const clean=extensionId(id);
+ const state=configureStateForExtension(clean);
+ const record=configureRegistrations.get(clean);
+ if(!state.available||state.pending||!record) return false;
+ const opener=options&&options.opener;
+ const onError=options&&options.onError;
+ let settled=false;
+ let failureReported=false;
+ record.pending=true;
+ notifyConfigureChange(clean,'pending');
+
+ function settle(){
+ if(settled) return false;
+ settled=true;
+ record.pending=false;
+ notifyConfigureChange(clean,'pending');
+ focusConfigureTarget(clean,opener);
+ return true;
+ }
+
+ function fail(error){
+ if(!failureReported){
+ failureReported=true;
+ reportConfigureFailure(clean,error,onError);
+ }
+ settle();
+ }
+
+ let result;
+ try{
+ result=record.handler(Object.freeze({opener,restoreFocus:settle}));
+ }catch(error){
+ fail(error);
+ return true;
+ }
+
+ let then;
+ try{
+ then=result!==null&&(typeof result==='object'||typeof result==='function')?result.then:null;
+ }catch(error){
+ fail(error);
+ return true;
+ }
+ if(typeof then==='function'){
+ try{
+ then.call(result,settle,fail);
+ }catch(error){
+ fail(error);
+ }
+ }
+ return true;
+ }
+
function turnLifecycleKey(sessionId,streamId){
return `${sessionId}\u0000${streamId}`;
}
@@ -416,7 +607,7 @@
if(!trusted) return null;
const handle=Object.freeze({
id:clean,
- settings:settingsAccessor(clean,trusted,true),
+ settings:settingsAccessor(clean,trusted,true,true),
storage:storageAccessor(clean,trusted),
events:eventAccessor(clean),
});
@@ -431,6 +622,9 @@
settingsForExtension,
storageForExtension,
_dispatchTurnLifecycle:dispatchTurnLifecycle,
+ _configureStateForExtension:configureStateForExtension,
+ _invokeConfigure:invokeConfigure,
+ _onConfigureChange:onConfigureChange,
resetSettingsForExtension(id){return settingsForExtension(id).reset();},
clearStorageForExtension(id){return storageForExtension(id).clear();},
};
diff --git a/static/panels.js b/static/panels.js
index b00c8937..95b6ce7c 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -9881,7 +9881,18 @@ function _extensionSettingsControls(entry){
`;
}
-function _extensionInstalledList(extensions,extensionDirConfigured){
+function _extensionConfigureButton(entry,surface){
+ if(surface!=='installed'||!(entry&&entry.effective_enabled)) return '';
+ const id=(entry&&entry.id)||'';
+ const runtime=window.HermesExtensionSettings;
+ if(!id||!runtime||typeof runtime._configureStateForExtension!=='function') return '';
+ const state=runtime._configureStateForExtension(id);
+ if(!state||!state.available) return '';
+ const pending=state.pending===true;
+ return ``;
+}
+
+function _extensionInstalledList(extensions,extensionDirConfigured,surface){
const list=Array.isArray(extensions)?extensions:[];
if(!list.length){
if(!extensionDirConfigured) return '
No extension directory is configured.
';
@@ -9898,6 +9909,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
const note=canToggle
? 'Toggles the WebUI-managed override for the next app load.'
: 'Manifest-disabled entries cannot be enabled from WebUI.';
+ const configureButton=_extensionConfigureButton(entry,surface);
return `
@@ -9907,7 +9919,10 @@ function _extensionInstalledList(extensions,extensionDirConfigured){