ouroboros/web/modules/settings.js
Andrei Kaznacheev 07c68f847d feat(remote): the web surface — the remote connection card and its admin tab
A remote task's card says whose machine the work is on, what the connection is
doing, and what the owner may do about it — reconnect, or cancel.

The split is deliberate and three-way. `remote_task_state.js` holds the
DECISIONS: what a status means, which actions are offered, how one
connection-wide frame fans out across the tasks riding that connection.
`remote_card_ui.js` is the VIEW and decides nothing; it takes its host's
capabilities as one explicit object, so "the card renderer reached into chat
state" cannot happen by accident and a test can drive it with a stub.
`connections_ui.js` is the owner's admin surface.

Cancel from the card CASCADES, like every other cancel on this surface —
cancelling a remote orchestrator without it left its subagents running on the
target — and the optimistic status it writes is a terminal one, because the
previous spelling was in neither the cancellable nor the terminal set and left
the card in a limbo that offered no action and never read as finished.
2026-08-15 18:59:40 +04:00

1350 lines
65 KiB
JavaScript

import { refreshModelCatalog } from './settings_catalog.js';
import { bindEffortSegments, syncEffortSegments } from './settings_controls.js';
import { bindLocalModelControls } from './settings_local_model.js';
import { applyMcpSettings, collectMcpSettings, initMcpSettings } from './mcp_settings.js';
import { collectReviewerSlots, initReviewerSlots, reloadReviewerSlots } from './reviewer_slots.js';
import {
applySubagentsSettings,
collectSubagentsSettings,
initSubagentsSection,
reloadSubagentsSection,
} from './subagents_settings.js';
import { initHarnessAccounts } from './harness_accounts.js';
import { openConfirmDialog } from './confirm_dialog.js';
import { SECRET_KEYS, bindSecretInputs, bindSettingsTabs, renderSettingsPage } from './settings_ui.js';
import { showToast } from './toast.js';
import { escapeHtmlAttr as escapeHtml, formatDualVersion } from './utils.js';
import { apiClient, apiFetch, cleanExtensionRoute, extensionRoutePath } from './api_client.js';
import { initConnectionsUI } from './connections_ui.js';
import { collectSafeFieldValues, renderSafeField, setInlineStatus } from './ui_helpers.js';
let markSettingsDirty = () => {};
const BASE_SECRET_KEYS = new Set(SECRET_KEYS.map(([key]) => key));
const pendingExtensionSettings = new Set();
let setupContract = {};
const INPUT_FIELDS = [
['s-openai-base-url', 'OPENAI_BASE_URL'], ['s-openai-compatible-base-url', 'OPENAI_COMPATIBLE_BASE_URL'], ['s-cloudru-base-url', 'CLOUDRU_FOUNDATION_MODELS_BASE_URL'],
['s-gigachat-scope', 'GIGACHAT_SCOPE'], ['s-gigachat-user', 'GIGACHAT_USER'], ['s-gigachat-base-url', 'GIGACHAT_BASE_URL'], ['s-gigachat-verify-ssl', 'GIGACHAT_VERIFY_SSL_CERTS'],
// 6.4 (D10): the Claude Code model picker is gone with claude_code_edit;
// CLAUDE_CODE_MODEL stays a backend setting for the api-route advisory.
['s-minimax-region', 'MINIMAX_REGION'],
['s-server-host', 'OUROBOROS_SERVER_HOST', '127.0.0.1'],
// 6.1: OUROBOROS_REVIEW_MODELS / OUROBOROS_SCOPE_REVIEW_MODELS are no
// longer authored here — the Review lanes section composes the ONE
// structured setting; the comma keys stay a backend-derived projection.
['s-deep-self-review-model', 'OUROBOROS_MODEL_DEEP_SELF_REVIEW'], ['s-skills-repo-path', 'OUROBOROS_SKILLS_REPO_PATH'],
['s-clawhub-registry-url', 'OUROBOROS_CLAWHUB_REGISTRY_URL'], ['s-websearch-model', 'OUROBOROS_WEBSEARCH_MODEL'], ['s-gh-repo', 'GITHUB_REPO'],
['s-local-source', 'LOCAL_MODEL_SOURCE'], ['s-local-filename', 'LOCAL_MODEL_FILENAME'], ['s-local-chat-format', 'LOCAL_MODEL_CHAT_FORMAT'],
['s-subagent-worktree-root', 'OUROBOROS_SUBAGENT_WORKTREE_ROOT'], ['s-subagent-projects-root', 'OUROBOROS_SUBAGENT_PROJECTS_ROOT'],
['s-evo-budget', 'OUROBOROS_POST_TASK_EVOLUTION_BUDGET_USD', '0'],
['s-evo-objective', 'OUROBOROS_EVOLUTION_PERSISTENT_OBJECTIVE', ''],
];
const VALUE_FIELDS = [
// 6.3: Review / Scope Review efforts are per-slot rows in Agents → Review
// lanes now; their global keys remain backend defaults, no longer UI-authored.
['s-effort-task', 'OUROBOROS_EFFORT_TASK', 'medium'], ['s-effort-evolution', 'OUROBOROS_EFFORT_EVOLUTION', 'high'],
['s-effort-consciousness', 'OUROBOROS_EFFORT_CONSCIOUSNESS', 'high'], ['s-effort-deep-self-review', 'OUROBOROS_EFFORT_DEEP_SELF_REVIEW', 'high'],
['s-review-enforcement', 'OUROBOROS_REVIEW_ENFORCEMENT', 'advisory'], ['s-task-review-mode', 'OUROBOROS_TASK_REVIEW_MODE', 'auto'], ['s-runtime-mode', 'OUROBOROS_RUNTIME_MODE', 'advanced'],
['s-update-channel', 'OUROBOROS_UPDATE_CHANNEL', 'stable'],
['s-context-mode', 'OUROBOROS_CONTEXT_MODE', 'max'], ['s-image-input-mode', 'OUROBOROS_IMAGE_INPUT_MODE', 'auto'],
['s-safety-mode', 'OUROBOROS_SAFETY_MODE', 'full'],
['s-prompt-cache-ttl', 'OUROBOROS_PROMPT_CACHE_TTL', '1h'],
];
const _SAFETY_MODE_RANK = { full: 2, light: 1, off: 0 };
const NUMBER_FIELDS = [
['s-workers', 'OUROBOROS_MAX_WORKERS', 10], ['s-active-subagents', 'OUROBOROS_MAX_ACTIVE_SUBAGENTS_PER_ROOT', 6], ['s-subagent-depth', 'OUROBOROS_MAX_SUBAGENT_DEPTH', 2, true],
['s-tool-timeout', 'OUROBOROS_TOOL_TIMEOUT_SEC', 600], ['s-local-port', 'LOCAL_MODEL_PORT', 8766], ['s-local-gpu-layers', 'LOCAL_MODEL_N_GPU_LAYERS', -1, true],
['s-local-ctx', 'LOCAL_MODEL_CONTEXT_LENGTH', 16384], ['s-gc-retention-days', 'OUROBOROS_GC_RETENTION_DAYS', 7],
['s-bg-wakeup-min', 'OUROBOROS_BG_WAKEUP_MIN', 30], ['s-bg-wakeup-max', 'OUROBOROS_BG_WAKEUP_MAX', 7200], ['s-bg-max-rounds', 'OUROBOROS_BG_MAX_ROUNDS', 10],
];
function setupModelSlots() {
return Array.isArray(setupContract.modelSlots) ? setupContract.modelSlots : [];
}
function byId(id) {
return document.getElementById(id);
}
function applyInputValue(id, value) {
byId(id).value = value === undefined || value === null ? '' : value;
}
function applyCheckboxValue(id, value) {
byId(id).checked = isTruthySetting(value);
}
function syncHeavyModelPlaceholder() {
// Owner decision: an empty Heavy slot legally inherits Main, so the empty
// field names the model it actually resolves to instead of looking unset.
// Display-only — nothing here writes settings.
const heavy = byId('s-model-heavy');
const main = byId('s-model');
if (!heavy || !main) return;
const mainValue = String(main.value || '').trim();
heavy.placeholder = mainValue ? `inherits Main (${mainValue})` : 'inherits Main';
}
function isTruthySetting(value) {
const normalized = String(value ?? '').trim().toLowerCase();
return value === true || ['true', '1', 'yes', 'on'].includes(normalized);
}
function setStatus(text, tone = 'ok') {
const status = byId('settings-status');
status.textContent = text;
status.dataset.tone = tone;
}
function readInt(id, fallback) {
const value = parseInt(byId(id).value, 10);
return Number.isNaN(value) ? fallback : value;
}
function resetSecretClearFlags(root) {
root.querySelectorAll('.secret-input').forEach((input) => {
delete input.dataset.forceClear;
input.type = 'password';
});
root.querySelectorAll('.secret-toggle').forEach((button) => {
button.textContent = 'Show';
});
}
function applySecretInputs(root, settings) {
root.querySelectorAll('[data-secret-setting]').forEach((input) => {
applyInputValue(input.id, settings[input.dataset.secretSetting]);
});
}
function wireSecretRow(row) {
const input = row.querySelector('.secret-input');
const toggle = row.querySelector('[data-row-secret-toggle]');
const clear = row.querySelector('[data-row-secret-clear]');
if (input) input.addEventListener('input', () => { if (input.value.trim()) delete input.dataset.forceClear; });
if (toggle && input) toggle.addEventListener('click', () => { input.type = input.type === 'password' ? 'text' : 'password'; toggle.textContent = input.type === 'password' ? 'Show' : 'Hide'; });
if (clear && input) clear.addEventListener('click', () => { input.value = ''; input.type = 'password'; input.dataset.forceClear = '1'; if (toggle) toggle.textContent = 'Show'; markSettingsDirty(); });
}
function customSecretRow(key = '', value = '') {
const id = `custom-secret-${Math.random().toString(36).slice(2)}`;
const row = document.createElement('div');
row.className = 'settings-custom-secret-row';
row.dataset.customSecretRow = '1';
row.innerHTML = `
<div class="form-field settings-custom-secret-key"><label>Key</label><input data-custom-secret-key value="${escapeHtml(key)}" placeholder="SLACK_WEBHOOK_URL" spellcheck="false"></div>
<div class="form-field settings-custom-secret-value"><label>Value</label><div class="secret-input-row">
<input id="${id}" data-custom-secret-value class="secret-input" type="password" value="${escapeHtml(value || '')}" placeholder="Secret value">
<button type="button" class="settings-ghost-btn" data-row-secret-toggle>Show</button>
<button type="button" class="settings-ghost-btn" data-row-secret-clear>Clear</button>
</div><div class="settings-inline-note" data-custom-secret-error hidden></div></div>
<button type="button" class="settings-ghost-btn settings-custom-secret-remove" data-custom-secret-remove>Remove</button>`;
wireSecretRow(row);
row.querySelector('[data-custom-secret-remove]')?.addEventListener('click', () => { row.dataset.removeCustomSecret = '1'; row.hidden = true; markSettingsDirty(); });
return row;
}
function renderCustomSecrets(root, settings) {
const host = root.querySelector('#custom-secrets-list');
if (!host) return;
host.innerHTML = '';
const keys = Array.isArray(settings?._meta?.custom_secret_keys) ? settings._meta.custom_secret_keys : [];
keys.forEach((key) => host.appendChild(customSecretRow(key, settings[key] || '')));
if (!keys.length) host.innerHTML = '<div class="muted">No custom keys yet.</div>';
}
function renderRequestedSkillSecrets(root, skills, settings) {
const host = root.querySelector('#skill-requested-secrets');
if (!host) return;
const keys = [];
(Array.isArray(skills) ? skills : []).forEach((skill) => {
(skill?.grants?.requested_keys || []).forEach((key) => {
const normalized = String(key || '').trim();
if (normalized && !BASE_SECRET_KEYS.has(normalized)) keys.push(normalized);
});
});
const unique = Array.from(new Set(keys)).sort((a, b) => a.localeCompare(b));
if (!unique.length) { host.innerHTML = '<div class="muted">No skill-requested secrets.</div>'; return; }
host.innerHTML = '';
unique.forEach((key, idx) => {
const id = `requested-secret-${idx}`;
const el = document.createElement('div');
el.className = 'settings-requested-secret-row';
el.innerHTML = `<div class="form-field"><label>${escapeHtml(key)}</label><div class="secret-input-row">
<input id="${id}" data-secret-setting="${escapeHtml(key)}" class="secret-input" type="password" value="${escapeHtml(settings[key] || '')}" placeholder="Secret value">
<button type="button" class="settings-ghost-btn" data-row-secret-toggle>Show</button>
<button type="button" class="settings-ghost-btn" data-row-secret-clear>Clear</button>
</div></div>`;
wireSecretRow(el); host.appendChild(el);
});
}
function renderExtensionSettingsSections(root, sections) {
const host = root.querySelector('#extension-settings-sections');
if (!host) return;
const items = Array.isArray(sections) ? sections : [];
if (!items.length) {
host.innerHTML = '<div class="muted">No extension settings registered.</div>';
return;
}
const formSpecs = new Map();
const componentHtml = (section, component, idx) => {
const type = String(component.type || '');
if (type === 'markdown') {
return `<div class="settings-section-copy">${escapeHtml(component.text || '')}</div>`;
}
if (type === 'json') {
return `<details class="widget-json"><summary>${escapeHtml(component.label || 'JSON')}</summary><pre>${escapeHtml(JSON.stringify(component.value || component.data || {}, null, 2))}</pre></details>`;
}
if (type === 'form' || type === 'action') {
const fields = Array.isArray(component.fields) ? component.fields : [];
const rawRoute = component.route || component.api_route || '';
if (!cleanExtensionRoute(rawRoute)) {
return '<div class="settings-inline-note">Invalid extension settings route.</div>';
}
const formKey = `${section.key || `${section.skill}:${section.section_id}`}:${component.id || idx}`;
formSpecs.set(formKey, component);
const disabled = Boolean(component.disabled);
const fieldOptions = {
disabled,
fieldClass: 'form-field',
inlineClass: 'settings-extension-checkbox',
helpClass: 'settings-inline-note',
};
return `
<form class="settings-extension-form" data-extension-settings-form data-extension-settings-key="${escapeHtml(formKey)}" data-skill="${escapeHtml(section.skill || '')}" data-route="${escapeHtml(rawRoute)}">
<div class="form-grid two">${fields.map((field) => renderSafeField(field, {}, fieldOptions)).join('')}</div>
<button class="btn btn-primary btn-sm" type="submit"${disabled ? ' disabled' : ''}>${escapeHtml(component.submit_label || component.label || 'Save')}</button>
<div class="settings-inline-status" data-extension-settings-status></div>
</form>
`;
}
return `<div class="settings-inline-note">Unsupported extension settings component ${idx + 1}: ${escapeHtml(type || 'unknown')}</div>`;
};
host.innerHTML = items.map((section) => {
const title = escapeHtml(section.title || section.section_id || section.key || 'Extension settings');
const skill = escapeHtml(section.skill || '');
const components = Array.isArray(section.render?.components) ? section.render.components : [];
return `
<article class="settings-extension-section">
<div class="settings-extension-section-head">
<strong>${title}</strong>
${skill ? `<span class="settings-inline-note">from ${skill}</span>` : ''}
</div>
<div class="settings-extension-components">
${components.length ? components.map((component, idx) => componentHtml(section, component, idx)).join('') : '<div class="muted">No declarative components.</div>'}
</div>
</article>
`;
}).join('');
host.querySelectorAll('[data-extension-settings-form]').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const status = form.querySelector('[data-extension-settings-status]');
const skill = form.dataset.skill || '';
const route = form.dataset.route || '';
const formKey = form.dataset.extensionSettingsKey || `${skill}:${route}`;
const spec = formSpecs.get(formKey) || {};
const requestKey = `${skill}:${route}`;
if (!skill || !route || spec.disabled || pendingExtensionSettings.has(requestKey)) return;
const values = collectSafeFieldValues(form, spec.fields || []);
const button = form.querySelector('button[type="submit"]');
const idleLabel = spec.submit_label || spec.label || 'Save';
pendingExtensionSettings.add(requestKey);
if (button) {
button.disabled = true;
button.textContent = spec.busy_label || 'Saving…';
}
setInlineStatus(status, 'Saving...', 'muted');
try {
const cleanRoute = cleanExtensionRoute(route);
if (!cleanRoute) throw new Error('invalid extension settings route');
const resp = await apiFetch(extensionRoutePath(skill, route), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data.error) throw new Error(data.error || `HTTP ${resp.status}`);
setInlineStatus(status, data.message || 'Saved.', 'ok');
} catch (err) {
setInlineStatus(status, err.message || String(err), 'danger');
} finally {
pendingExtensionSettings.delete(requestKey);
if (button) {
button.disabled = Boolean(spec.disabled);
button.textContent = idleLabel;
}
}
});
});
}
function collectSecretValue(id, body) {
const input = byId(id);
if (!input) return;
const settingKey = input.dataset.secretSetting;
if (!settingKey) return;
if (input.dataset.forceClear === '1') {
body[settingKey] = '';
return;
}
const value = input.value;
if (value && !value.includes('...')) body[settingKey] = value;
}
// Fallback picker pills mirror config defaults plus useful direct-provider ids.
const SETTINGS_FALLBACK_MODELS = [
'x-ai/grok-4.5',
'google/gemini-3.6-flash',
'openai/gpt-5.6-terra',
'openai/gpt-5.6-sol',
'openai/gpt-5.6-luna',
'openai::gpt-5.6-terra',
'openai::gpt-5.6-sol',
'openai::gpt-5.6-luna',
'anthropic/claude-sonnet-5',
'anthropic/claude-opus-5',
'anthropic::claude-sonnet-5',
'anthropic::claude-opus-5',
'anthropic::claude-opus-4-6',
'deepseek/deepseek-v4-pro',
'minimax::MiniMax-M3',
'minimax::MiniMax-M2.7',
];
let settingsModelCatalogItems = SETTINGS_FALLBACK_MODELS.map((value) => ({ value, label: 'Suggested model' }));
/**
* Pure predicate (v6.82 P2): should the collapsed Settings "More providers"
* section auto-open? True only for a USABLE credential — a provider API key,
* a GigaChat OAuth credential, or a COMPLETE GigaChat basic-auth pair. Base
* URLs/scope/TLS fields always carry shipped defaults and never count.
* Exported for dependency-free node tests.
*/
export function moreProvidersCredentialConfigured({
cloudruKey = '', minimaxKey = '', gigachatCredentials = '', gigachatUser = '', gigachatPassword = '',
} = {}) {
const has = (v) => Boolean(String(v ?? '').trim());
return has(cloudruKey)
|| has(minimaxKey)
|| has(gigachatCredentials)
|| (has(gigachatUser) && has(gigachatPassword));
}
export function initSettings({ state, setBeforePageLeave, ws } = {}) {
const page = document.createElement('div');
page.id = 'page-settings';
page.className = 'page app-page-glass';
page.innerHTML = renderSettingsPage();
document.getElementById('content').appendChild(page);
const activateSettingsTab = (tabName) => {
if (typeof page.activateSettingsTab === 'function') {
page.activateSettingsTab(tabName);
}
};
bindSettingsTabs(page, { state });
bindSecretInputs(page);
bindEffortSegments(page);
bindLocalModelControls({ state });
initConnectionsUI({ root: page, apiClient, ws });
// Best-effort About version from /api/health.
apiFetch('/api/health')
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((d) => {
const verEl = document.getElementById('about-version');
if (verEl) verEl.textContent = formatDualVersion(d);
})
.catch(() => { /* about version is best-effort */ });
let currentSettings = {};
let claudeCodePollStarted = false;
let extensionRefreshPending = false;
// Runtime errors must surface even before ANTHROPIC_API_KEY is configured.
let claudeRuntimeHasError = false;
let settingsLoaded = false;
let settingsBaseline = '';
let settingsDirty = false;
initMcpSettings({ onChange: updateSettingsDirtyState });
initReviewerSlots({ onChange: () => updateSettingsDirtyState() });
initSubagentsSection({ onChange: () => updateSettingsDirtyState() });
initHarnessAccounts();
function anthropicKeyConfigured() {
const input = byId('s-anthropic');
if (!input) return Boolean(String(currentSettings.ANTHROPIC_API_KEY || '').trim());
if (input.dataset.forceClear === '1') return false;
const liveValue = String(input.value || '').trim();
if (liveValue) return true;
return Boolean(String(currentSettings.ANTHROPIC_API_KEY || '').trim());
}
function shouldShowClaudeRuntimeCard() {
return anthropicKeyConfigured() || claudeRuntimeHasError;
}
function renderClaudeCodeUi() {
const panel = byId('settings-claude-code-panel');
const note = byId('settings-claude-code-copy');
const button = byId('btn-claude-code-install');
const visible = shouldShowClaudeRuntimeCard();
if (panel) panel.hidden = !visible;
if (note) note.hidden = !visible;
if (!visible) return;
if (button && button.dataset.busy !== '1' && button.dataset.ready !== '1') {
button.disabled = false;
button.textContent = 'Repair Runtime';
}
}
function syncSettingsLoadState() {
const saveBtn = byId('btn-save-settings');
if (saveBtn) {
saveBtn.disabled = !settingsLoaded;
saveBtn.title = settingsLoaded
? ''
: 'Reload current settings successfully before saving.';
}
}
function syncRuntimeModeBridgeState() {
const hasBridge = Boolean(window.pywebview?.api?.request_runtime_mode_change);
const group = document.querySelector('[data-runtime-mode-group]');
if (group) {
group.title = hasBridge
? 'Runtime mode changes require native launcher confirmation and restart.'
: 'Runtime mode changes are saved through the owner endpoint and take effect after restart.';
}
document.querySelectorAll('[data-runtime-mode-group] [data-effort-value]').forEach((button) => {
button.disabled = false;
});
}
function syncPostTaskEvolutionUi() {
const mode = byId('s-post-task-evolution-mode')?.value || 'off';
page.querySelectorAll('[data-evo-every-n-row]').forEach((row) => {
row.hidden = mode !== 'every_n';
});
}
function snapshotSettingsDraft() {
return JSON.stringify({
...collectBody(),
OUROBOROS_RUNTIME_MODE_DRAFT: byId('s-runtime-mode')?.value || 'advanced',
OUROBOROS_CONTEXT_MODE_DRAFT: byId('s-context-mode')?.value || 'max',
});
}
function setSettingsCleanBaseline() {
settingsBaseline = snapshotSettingsDraft();
settingsDirty = false;
const indicator = byId('settings-unsaved-indicator');
if (indicator) indicator.classList.remove('is-visible');
}
function updateSettingsDirtyState() {
if (!settingsLoaded || !settingsBaseline) return;
const nextDirty = snapshotSettingsDraft() !== settingsBaseline;
if (nextDirty === settingsDirty) return;
settingsDirty = nextDirty;
const indicator = byId('settings-unsaved-indicator');
if (indicator) indicator.classList.toggle('is-visible', settingsDirty);
}
function discardUnsavedSettingsDraft() {
closeSettingsModelPickers();
applySettings(currentSettings || {});
setSettingsCleanBaseline();
setStatus('', 'ok');
}
function applyClaudeCodeStatus(payload = {}) {
const button = byId('btn-claude-code-install');
const status = byId('settings-claude-code-status');
const ready = Boolean(payload.ready);
const installed = Boolean(payload.installed);
const busy = Boolean(payload.busy);
const error = String(payload.error || '').trim();
// Backend error state controls visibility without an API key.
claudeRuntimeHasError = Boolean(error);
const message = String(payload.message || '').trim()
|| (ready ? 'Claude runtime ready.' : (installed ? 'Claude runtime available but not ready.' : 'Claude runtime not available.'));
const tone = ready ? 'ok' : (error ? 'error' : (installed ? 'muted' : 'error'));
if (status) {
status.textContent = message;
status.dataset.tone = tone;
}
if (button) {
button.dataset.busy = busy ? '1' : '0';
button.dataset.ready = ready ? '1' : '0';
button.dataset.installed = installed ? '1' : '0';
button.disabled = busy;
button.textContent = busy ? 'Repairing...' : (ready ? 'Runtime OK' : 'Repair Runtime');
}
renderClaudeCodeUi();
}
async function refreshClaudeCodeStatus() {
// Poll even without API key; backend separates no_api_key from errors.
try {
const resp = await apiFetch('/api/claude-code/status', { cache: 'no-store' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
applyClaudeCodeStatus(data);
} catch (error) {
applyClaudeCodeStatus({
installed: false,
ready: false,
busy: false,
error: String(error?.message || error || ''),
message: `Claude runtime status check failed: ${String(error?.message || error || '')}`,
});
}
}
function syncAutoGrantBridgeState() {
const hasBridge = Boolean(window.pywebview?.api?.request_auto_grant_reviewed_skills_change);
const checkbox = byId('s-auto-grant-reviewed-skills');
const label = checkbox?.closest('.local-toggle');
if (checkbox) checkbox.disabled = false;
if (label) {
label.title = hasBridge
? 'Requires native confirmation. Applies only after a fresh executable skill review and only to manifest-declared grants for that exact content hash.'
: 'Uses the owner endpoint. Applies only after a fresh executable skill review and only to manifest-declared grants for that exact content hash.';
}
}
function startClaudeCodePolling() {
if (claudeCodePollStarted) return;
claudeCodePollStarted = true;
refreshClaudeCodeStatus();
setInterval(() => {
refreshClaudeCodeStatus();
}, 3000);
}
function applySettings(s) {
setupContract = s?._meta?.setup_contract || setupContract || {};
applySecretInputs(page, s);
INPUT_FIELDS.forEach(([id, key, fallback = '']) => applyInputValue(id, fallback && !s[key] ? fallback : s[key]));
VALUE_FIELDS.forEach(([id, key, fallback]) => { byId(id).value = s[key] || fallback; });
setupModelSlots().forEach((slot) => {
applyInputValue(slot.settingsInputId, s[slot.settingKey]);
if (slot.settingsToggleId) applyCheckboxValue(slot.settingsToggleId, s[`USE_LOCAL_${slot.slot.toUpperCase()}`]);
});
syncHeavyModelPlaceholder();
applyCheckboxValue('s-auto-grant-reviewed-skills', s.OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS);
// Owner-facing mutative-subagents control shows the EFFECTIVE state when it
// is binary-representable: an explicit value, or unset in advanced/pro
// (every acting surface on = "On"). Unset in LIGHT mode is surface-aware
// (external_workspace/genesis stay on, self_worktree off — see
// config.get_allow_mutative_subagents), so neither Off nor On is truthful
// there: it displays as "Auto". Picking Auto saves the empty value
// (collectBody maps any non-on/off segment to ''), so the mode default
// keeps deciding.
const rawMutative = String(s.OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS ?? '').trim().toLowerCase();
const runtimeMode = String(s.OUROBOROS_RUNTIME_MODE || 'advanced').trim().toLowerCase();
const mutativeInput = byId('s-allow-mutative-subagents');
mutativeInput.dataset.rawValue = rawMutative;
delete mutativeInput.dataset.effortTouched;
mutativeInput.value =
({ true: 'on', false: 'off' }[rawMutative] || (runtimeMode === 'light' ? 'auto' : 'on'));
// The delegation route lives next to it in Agents → Delegation.
applySubagentsSettings(s);
// Post-task evolution: one owner-facing selector maps to enable + cadence.
const evoEnabled =
({ true: 'on', '1': 'on', on: 'on', false: 'off', '0': 'off', off: 'off' }[
String(s.OUROBOROS_POST_TASK_EVOLUTION ?? '').trim().toLowerCase()] || 'off') === 'on';
const evoCadence = String(s.OUROBOROS_POST_TASK_EVOLUTION_CADENCE || 'llm').trim().toLowerCase();
// Use the SAME strict shape as the backend (^every_n:[1-9]\d*$) so a stale/
// malformed value (e.g. every_nonsense, every_n:0) displays as llm — never as
// Every-N:3, which a later Save would silently persist as periodic evolution.
const everyNMatch = evoCadence.match(/^every_n:([1-9]\d*)$/);
if (!evoEnabled) {
byId('s-post-task-evolution-mode').value = 'off';
} else if (everyNMatch) {
byId('s-post-task-evolution-mode').value = 'every_n';
byId('s-evo-cadence-n').value = everyNMatch[1];
} else {
byId('s-post-task-evolution-mode').value = 'llm';
}
NUMBER_FIELDS.forEach(([id, key, fallback, allowFalsy]) => {
const value = s[key];
if (allowFalsy ? value !== null && value !== undefined : value) byId(id).value = value;
else byId(id).value = fallback;
});
(Array.isArray(setupContract.budgetFields) ? setupContract.budgetFields : []).forEach((field) => {
const id = field.settingsInputId;
const input = byId(id);
if (!input) return;
input.min = field.min || '0.01';
input.step = field.step || 'any';
input.value = s[field.settingKey] ?? field.default ?? '';
});
applyMcpSettings(s);
syncMoreProvidersDisclosure();
resetSecretClearFlags(page);
syncEffortSegments(page);
syncRuntimeModeBridgeState();
syncPostTaskEvolutionUi();
refreshSafetySkipCounter(); // fire-and-forget; fills the 24h audited-skip note
}
function syncMoreProvidersDisclosure() {
// Auto-open the collapsed "More providers" section when a usable
// provider CREDENTIAL inside it is configured, so a set-up
// A configured provider in this section is never hidden. Non-secret inputs
// (base URLs, scope, verify-ssl) always carry shipped defaults and
// must NOT count as "configured". Runs after applySettings; never
// force-closes an owner-opened section.
const wrapper = byId('settings-more-providers');
if (!wrapper) return;
const value = (id) => {
const input = byId(id);
return input ? input.value : '';
};
if (moreProvidersCredentialConfigured({
cloudruKey: value('s-cloudru-key'),
minimaxKey: value('s-minimax-key'),
gigachatCredentials: value('s-gigachat-credentials'),
gigachatUser: value('s-gigachat-user'),
gigachatPassword: value('s-gigachat-password'),
})) wrapper.open = true;
}
function _renderNetworkHint(meta) {
const hint = document.getElementById('settings-lan-hint');
if (!hint || !meta) return;
if (meta.reachability === 'loopback_only') {
hint.innerHTML = 'Bound to <code>localhost</code>: only accessible from this machine. Set Server Bind Host to <code>0.0.0.0</code>, save, and restart for LAN access.';
hint.dataset.tone = 'info';
hint.hidden = false;
} else if (meta.reachability === 'lan_reachable') {
const url = escapeHtml(meta.recommended_url || '');
const warning = escapeHtml(meta.warning || '');
hint.innerHTML = `LAN URL: <a href="${url}" target="_blank" rel="noopener">${url}</a>${warning ? ' — <strong>' + warning + '</strong>' : ''}`;
hint.dataset.tone = meta.warning ? 'warn' : 'ok';
hint.hidden = false;
} else if (meta.reachability === 'host_ip_unknown') {
const url = escapeHtml(meta.recommended_url || '');
const warning = escapeHtml(meta.warning || '');
hint.innerHTML = `Server is listening on non-localhost but LAN IP could not be detected automatically. Try <code>${url}</code>.${warning ? ' <strong>' + warning + '</strong>' : ''}`;
hint.dataset.tone = 'warn';
hint.hidden = false;
} else {
hint.hidden = true;
}
}
async function loadSettings() {
const [data, extData] = await Promise.all([
apiClient.settings(),
apiClient.extensions().catch(() => ({})),
]);
const sections = Array.isArray(extData?.live?.settings_sections)
? extData.live.settings_sections
: [];
currentSettings = data;
applySettings(data);
renderExtensionSettingsSections(page, sections);
renderRequestedSkillSecrets(page, extData.skills || [], data);
renderCustomSecrets(page, data);
// Await the reviewer rows and the Subagents accounts BEFORE the clean
// baseline: their async arrival must not read as an unsaved owner edit.
await Promise.all([reloadReviewerSlots(), reloadSubagentsSection()]);
setSettingsCleanBaseline();
closeSettingsModelPickers();
_renderNetworkHint(data._meta);
renderClaudeCodeUi();
settingsLoaded = true;
markSettingsDirty = updateSettingsDirtyState;
syncSettingsLoadState();
startClaudeCodePolling();
}
async function reloadSettingsWithFeedback() {
setStatus('Loading settings...', 'muted');
settingsLoaded = false;
syncSettingsLoadState();
try {
await loadSettings();
try {
await refreshModelCatalog();
setStatus('Settings loaded', 'ok');
} catch (error) {
setStatus(
`Settings loaded. Model catalog refresh failed: ${error.message || error}`,
'warn'
);
}
} catch (error) {
settingsLoaded = false;
syncSettingsLoadState();
setStatus(
`Failed to load current settings. Save is disabled until reload succeeds: ${error.message || error}`,
'warn'
);
}
}
async function refreshSettingsAfterExtensionChange(reason = 'skills changed') {
if (extensionRefreshPending) return;
if (settingsDirty) {
setStatus(`Settings changed externally (${reason}). Reload after saving or discarding your draft.`, 'warn');
return;
}
extensionRefreshPending = true;
try {
await loadSettings();
setStatus('Settings refreshed', 'ok');
} catch (error) {
setStatus(`Settings refresh failed: ${error.message || error}`, 'warn');
} finally {
extensionRefreshPending = false;
}
}
function collectBody() {
const fieldValue = (id) => byId(id)?.value || '';
const mutativeInput = byId('s-allow-mutative-subagents');
const rawMutative = String(mutativeInput?.dataset?.rawValue ?? '').trim().toLowerCase();
const mutativeTouched = mutativeInput?.dataset?.effortTouched === '1';
const body = {
OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS: byId('s-auto-grant-reviewed-skills')?.checked ? 'true' : 'false',
OUROBOROS_ALLOW_MUTATIVE_SUBAGENTS: mutativeTouched
? ({ on: 'true', off: 'false' }[mutativeInput?.value] ?? '')
: (rawMutative ? ({ true: 'true', false: 'false' }[rawMutative] ?? rawMutative) : ''),
...collectMcpSettings(),
// 6.1: the ONE structured reviewer-slot setting; {} until the rows
// view has loaded, so an unrelated save cannot blank it.
...collectReviewerSlots(),
// Same rule for the delegated-subagent route: {} until the accounts
// read succeeded, so an unrelated save cannot turn delegation off
// because this page could not reach the daemon.
...collectSubagentsSettings(),
};
setupModelSlots().forEach((slot) => {
body[slot.settingKey] = fieldValue(slot.settingsInputId);
if (slot.settingsToggleId) body[`USE_LOCAL_${slot.slot.toUpperCase()}`] = Boolean(byId(slot.settingsToggleId)?.checked);
});
INPUT_FIELDS.forEach(([id, key, fallback = '']) => {
const value = fieldValue(id).trim();
body[key] = key === 'OUROBOROS_SERVER_HOST' ? value || fallback : value || '';
});
VALUE_FIELDS
// Owner-only keys travel through their audited owner endpoints, never
// the generic settings POST (safety_mode joined runtime/context, r4).
.filter(([, key]) => key !== 'OUROBOROS_RUNTIME_MODE' && key !== 'OUROBOROS_CONTEXT_MODE' && key !== 'OUROBOROS_SAFETY_MODE')
.forEach(([id, key]) => { body[key] = fieldValue(id); });
NUMBER_FIELDS.forEach(([id, key, fallback]) => { body[key] = readInt(id, fallback); });
(Array.isArray(setupContract.budgetFields) ? setupContract.budgetFields : []).forEach((field) => {
const id = field.settingsInputId;
const input = byId(id);
if (!input) return;
const raw = String(input.value || '').trim();
const parsed = Number(raw);
const value = Number.isFinite(parsed) && parsed > 0 ? parsed : raw;
if (String(value) !== String(currentSettings?.[field.settingKey] ?? field.default)) {
body[field.settingKey] = value;
}
});
// Post-task evolution: compose the legacy enable + cadence settings from
// the single owner-facing selector.
const evoCadMode = byId('s-post-task-evolution-mode').value;
body.OUROBOROS_POST_TASK_EVOLUTION = evoCadMode === 'off' ? 'false' : 'true';
body.OUROBOROS_POST_TASK_EVOLUTION_CADENCE = evoCadMode === 'every_n'
? `every_n:${Math.max(1, parseInt(byId('s-evo-cadence-n').value, 10) || 3)}`
: 'llm';
page.querySelectorAll('[data-secret-setting]').forEach((input) => {
collectSecretValue(input.id, body);
});
page.querySelectorAll('[data-custom-secret-row]').forEach((row) => {
const keyInput = row.querySelector('[data-custom-secret-key]');
const valueInput = row.querySelector('[data-custom-secret-value]');
const key = (keyInput?.value || '').trim().toUpperCase();
const error = row.querySelector('[data-custom-secret-error]');
if (!key) return;
if (!/^[A-Z][A-Z0-9_]{2,}$/.test(key)) { if (error) { error.hidden = false; error.textContent = 'Use uppercase letters, numbers, and underscores.'; } return; }
if (row.dataset.removeCustomSecret === '1' || valueInput?.dataset.forceClear === '1') { body[key] = ''; return; }
const value = valueInput?.value || '';
if (value && !value.includes('...')) body[key] = value;
});
return body;
}
async function saveRuntimeModeViaNativeBridgeIfNeeded() {
const nextMode = byId('s-runtime-mode').value || 'advanced';
const currentMode = currentSettings?.OUROBOROS_RUNTIME_MODE || 'advanced';
const bridge = window.pywebview?.api?.request_runtime_mode_change;
if (nextMode === currentMode) {
return bridge ? await bridge(nextMode) : await apiClient.ownerRuntimeMode(nextMode);
}
// Only the browser-side confirm is migrated to the in-house dialog; the
// desktop pywebview bridge path above stays exactly as it was.
const result = bridge
? await bridge(nextMode)
: ((await openConfirmDialog({
title: 'Change runtime mode',
body: `Change Ouroboros runtime mode from ${currentMode} to ${nextMode}? The change takes effect after restart.`,
confirmLabel: 'Change mode',
}))
? await apiClient.ownerRuntimeMode(nextMode)
: { ok: false, error: 'Runtime mode change cancelled.' });
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Runtime mode change was cancelled.');
}
return result;
}
async function saveAutoGrantViaNativeBridgeIfNeeded() {
const checkbox = byId('s-auto-grant-reviewed-skills');
if (!checkbox) return null;
const nextEnabled = Boolean(checkbox.checked);
const currentEnabled = isTruthySetting(currentSettings?.OUROBOROS_AUTO_GRANT_REVIEWED_SKILLS);
if (nextEnabled === currentEnabled) return null;
const bridge = window.pywebview?.api?.request_auto_grant_reviewed_skills_change;
// Browser-side confirm only; the pywebview bridge path stays untouched.
const result = bridge
? await bridge(nextEnabled)
: ((await openConfirmDialog({
title: 'Reviewed-skill auto-grant',
body: `${nextEnabled ? 'Enable' : 'Disable'} reviewed-skill auto-grant? It only applies after a fresh executable review for the current content hash.`,
confirmLabel: nextEnabled ? 'Enable' : 'Disable',
}))
? await apiClient.ownerAutoGrant(nextEnabled)
: { ok: false, error: 'Reviewed-skill auto-grant change cancelled.' });
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Reviewed-skill auto-grant change was cancelled.');
}
return result;
}
async function saveSafetyModeViaOwnerEndpointIfNeeded() {
// Owner-only, dropped from the generic /api/settings POST — saved through the
// dedicated audited endpoint. Confirm on LOWERING coverage (full > light > off).
const input = byId('s-safety-mode');
if (!input) return null;
const next = input.value || 'full';
const current = currentSettings?.OUROBOROS_SAFETY_MODE || 'full';
if (next === current) return null;
const lowering = (_SAFETY_MODE_RANK[next] ?? 2) < (_SAFETY_MODE_RANK[current] ?? 2);
if (lowering) {
const ok = await openConfirmDialog({
title: 'Lower safety supervisor',
body: `Lower the LLM safety supervisor from ${current} to ${next}?\n\n` +
`The deterministic sandbox, protected-path policy, and light-mode guards STAY ON in every mode. ` +
`Only the LLM safety-check layer is reduced, and every waved-through check is logged as an audit event.`,
confirmLabel: 'Lower safety mode',
danger: true,
});
if (!ok) throw new Error('Safety mode change was not confirmed.');
}
const result = await apiClient.ownerSafetyMode(next);
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Safety mode change failed.');
}
return result;
}
async function refreshSafetySkipCounter() {
// 24h count of durable safety_mode_skip audit events, so the owner sees how much
// the reduced coverage actually waved through.
const el = byId('s-safety-skip-counter');
if (!el) return;
try {
const data = await apiClient.logsTail('events', 2000);
const cutoff = Date.now() - 24 * 3600 * 1000;
const n = (data?.entries || []).filter((e) => {
if (String(e?.type || '') !== 'safety_mode_skip') return false;
const t = Date.parse(String(e?.ts || ''));
return Number.isFinite(t) && t >= cutoff;
}).length;
// Honest window note: the count scans the recent events tail (2000), so a
// very busy day can undercount — say "recent", never overclaim exactness.
el.textContent = n > 0
? `${n} safety check(s) waved through in the last 24h (audited; recent events window).`
: 'No safety checks waved through in the last 24h (recent events window).';
} catch {
el.textContent = '';
}
}
// A pinned scope reviewer's route has no other reachable path to Capability
// Evidence: the settings save probes it and returns the SAME needs_ack contract the
// Max gate uses, so reuse that flow verbatim. Without rendering it the owner only
// ever sees commits blocked by SCOPE_REVIEW_SUB_FLOOR telling them to owner-ack a
// route the UI never offered. Declining leaves the slot fail-closed, as before.
async function ackReviewCapabilityNotices(notices) {
const pending = (Array.isArray(notices) ? notices : [])
.filter((notice) => notice?.needs_ack?.model);
let acked = 0;
for (const notice of pending) {
const ack = notice.needs_ack;
const seen = Number(notice.window_tokens || 0);
// Each delivery is judged by ITS OWN floor: the api row by the
// constitutional 1M, a RETRIEVING row by the 200K session floor. Asking
// about 1M for a retrieving row would demand a confirmation its own gate
// never wanted, so the floor rides with the notice.
const floor = Number(notice.floor_tokens || 0) || 1000000;
const floorText = floor.toLocaleString('en-US');
// A STALE record can report a full 1M and still not authorize, so say WHY
// the ack is being asked for — otherwise the prompt reads "this route
// reports 1000000 tokens, please confirm 1000000 tokens".
const reading = !(seen > 0)
? 'no window metadata'
: (notice?.needs_ack?.evidence?.stale
? `${seen} tokens from an EXPIRED reading the provider could not re-confirm`
: `${seen} tokens`);
const confirmed = await openConfirmDialog({
title: 'Confirm scope-reviewer context window',
body: `Scope review is fail-closed unless its reviewer's ${floorText}-token context `
+ `window is currently known, and this route reports ${reading}.\n\n`
+ `Confirm that this reviewer supports a ${floorText}-token context window?\n`
+ `provider: ${ack.provider || '(default)'}\nmodel: ${ack.model}\n`
+ `base_url: ${ack.base_url || '(default)'}\n\n`
+ 'This applies only to this exact model/provider. Cancelling leaves scope '
+ 'review blocking commits on this route.',
confirmLabel: 'Confirm window',
});
if (!confirmed) continue;
await apiClient.ownerCapabilityAck({
provider: ack.provider, model: ack.model, base_url: ack.base_url,
window_tokens: floor, note: 'owner-confirmed scope reviewer window',
});
acked += 1;
}
return acked;
}
async function saveContextModeViaOwnerEndpointIfNeeded() {
const input = byId('s-context-mode');
if (!input) return null;
const next = input.value || 'max';
const current = currentSettings?.OUROBOROS_CONTEXT_MODE || 'max';
// A displayed `low` that is a system AUTO-DOWNGRADE is not an owner selection
// (see the chat toggle): re-picking Low must still POST so the idempotent
// endpoint clears the derived flag, otherwise an install whose route cannot be
// confirmed >=1M has no reachable way to declare Low and every commit blocks.
const derivedLow = String(currentSettings?.OUROBOROS_CONTEXT_MODE_AUTO_LOW || '')
.trim().toLowerCase() === 'true';
if (next === current && !(next === 'low' && derivedLow)) return null;
// Owner-only + hot-apply (saves immediately, no restart). Max needs the active model's
// 1M-token window confirmed; on a 409 needs_ack, share the chat-toggle's ack flow
// (CW8) — confirm, POST the route-scoped capability-ack, retry — instead of a
// generic failure.
try {
const result = await apiClient.ownerContextMode(next);
if (!result || result.ok !== true) {
throw new Error(result?.error || 'Context mode change failed.');
}
return result;
} catch (e) {
const ack = (e && e.status === 409 && e.body && e.body.needs_ack) ? e.body.needs_ack : null;
if (!(next === 'max' && ack && ack.model)) {
throw e;
}
const confirmed = await openConfirmDialog({
title: 'Confirm 1M-token context window',
body: `${(e.body && e.body.error) || 'Max context mode needs a confirmed 1M-token window.'}\n\n` +
`Confirm that this model supports a 1,000,000-token context window?\n` +
`provider: ${ack.provider || '(default)'}\nmodel: ${ack.model}\n` +
`base_url: ${ack.base_url || '(default)'}\n\n` +
`This applies only to this exact model/provider and is removed if you change it.`,
confirmLabel: 'Confirm window',
});
if (!confirmed) {
throw new Error('Max context mode was not confirmed.');
}
// Throws on a non-ok ack (surfaced by the save handler's catch).
await apiClient.ownerCapabilityAck({
provider: ack.provider, model: ack.model, base_url: ack.base_url,
window_tokens: 1000000, note: 'owner-confirmed via settings save',
});
const retry = await apiClient.ownerContextMode(next);
if (!retry || retry.ok !== true) {
throw new Error(retry?.error || 'Context mode change failed after confirmation.');
}
return retry;
}
}
syncSettingsLoadState();
syncRuntimeModeBridgeState();
syncAutoGrantBridgeState();
reloadSettingsWithFeedback();
if (typeof setBeforePageLeave === 'function') {
// app.js showPage() awaits every beforePageLeave handler, so the async
// dialog is legal here. Rapid double-navigation cannot double-fire the
// discard: opening a second dialog resolves the first as false (stay),
// so at most one confirmed leave runs discardUnsavedSettingsDraft().
setBeforePageLeave(async ({ from }) => {
if (from !== 'settings' || !settingsDirty) return true;
const leave = await openConfirmDialog({
title: 'Unsaved settings',
body: 'You have unsaved settings changes. Discard them and leave Settings?',
confirmLabel: 'Discard and leave',
cancelLabel: 'Stay',
});
if (leave) discardUnsavedSettingsDraft();
return leave;
});
}
byId('s-anthropic')?.addEventListener('input', () => {
renderClaudeCodeUi();
if (anthropicKeyConfigured()) {
startClaudeCodePolling();
refreshClaudeCodeStatus();
}
});
page.addEventListener('input', updateSettingsDirtyState);
page.addEventListener('change', updateSettingsDirtyState);
page.addEventListener('click', (event) => {
if (event.target.closest('[data-effort-value], .secret-clear, [data-row-secret-clear], [data-custom-secret-remove]')) {
queueMicrotask(() => {
syncPostTaskEvolutionUi();
updateSettingsDirtyState();
});
}
});
byId('btn-add-custom-secret')?.addEventListener('click', () => {
const host = byId('custom-secrets-list');
if (!host) return;
if (host.querySelector('.muted')) host.innerHTML = '';
const row = customSecretRow();
host.appendChild(row);
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.querySelector('[data-custom-secret-key]')?.focus();
markSettingsDirty();
});
window.addEventListener('ouro:skill-lifecycle', (event) => {
const action = String(event.detail?.action || 'skills changed');
refreshSettingsAfterExtensionChange(action);
});
window.addEventListener('ouro:settings-updated', (event) => {
if (event.detail?.source === 'settings') return;
const action = String(event.detail?.reason || 'settings changed');
refreshSettingsAfterExtensionChange(action);
});
if (ws && typeof ws.on === 'function') {
ws.on('extension_lifecycle', (event) => {
const action = String(event?.action || 'extension lifecycle');
refreshSettingsAfterExtensionChange(action);
});
}
window.addEventListener('ouro:page-shown', (event) => {
if (event.detail?.page === 'settings') refreshSettingsAfterExtensionChange('settings page shown');
});
function closeSettingsModelPickers(exceptPicker = null) {
page.querySelectorAll('[data-model-picker]').forEach((picker) => {
if (picker === exceptPicker) return;
const panel = picker.querySelector('.model-picker-results');
if (!panel) return;
panel.hidden = true;
panel.innerHTML = '';
});
}
function renderSettingsModelPicker(input) {
const picker = input.closest('[data-model-picker]');
const panel = picker?.querySelector('.model-picker-results');
if (!picker || !panel) return;
const needle = String(input.value || '').trim().toLowerCase();
let items = settingsModelCatalogItems
.filter((item) => {
const haystack = `${item.value} ${item.label || ''} ${item.provider || ''}`.toLowerCase();
return !needle || haystack.includes(needle);
})
.slice(0, 8);
if (!items.length && needle) {
items = settingsModelCatalogItems.slice(0, 8);
}
if (!items.length) {
panel.hidden = true;
panel.innerHTML = '';
return;
}
panel.innerHTML = items.map((item) => `
<button type="button" class="model-picker-item" data-value="${escapeHtml(item.value)}">
<span class="model-picker-item-value">${escapeHtml(item.value)}</span>
<span class="model-picker-item-label">${escapeHtml(item.label || item.provider || 'Catalog model')}</span>
</button>
`).join('');
panel.hidden = false;
}
page.addEventListener('focusin', (event) => {
const input = event.target instanceof Element
? event.target.closest('[data-model-picker] input')
: null;
if (!input) return;
const picker = input.closest('[data-model-picker]');
closeSettingsModelPickers(picker);
renderSettingsModelPicker(input);
});
page.dataset.modelPickerBound = '1';
page.addEventListener('input', (event) => {
const input = event.target instanceof Element
? event.target.closest('[data-model-picker] input')
: null;
if (!input) return;
const picker = input.closest('[data-model-picker]');
closeSettingsModelPickers(picker);
renderSettingsModelPicker(input);
});
// Keep the Heavy inherits-Main hint live while Main is edited (typed
// 'input') or picked from the catalog dropdown (dispatched 'change').
for (const eventType of ['input', 'change']) {
page.addEventListener(eventType, (event) => {
if (event.target instanceof Element && event.target.id === 's-model') {
syncHeavyModelPlaceholder();
}
});
}
page.addEventListener('mousedown', (event) => {
const item = event.target instanceof Element
? event.target.closest('.model-picker-item')
: null;
if (item) {
const picker = item.closest('[data-model-picker]');
const input = picker?.querySelector('input');
if (input) {
event.preventDefault();
input.value = item.dataset.value || '';
closeSettingsModelPickers();
input.dispatchEvent(new Event('change', { bubbles: true }));
}
return;
}
if (!(event.target instanceof Element) || !event.target.closest('[data-model-picker]')) {
closeSettingsModelPickers();
}
});
document.addEventListener('settings-model-catalog:updated', (event) => {
const items = Array.isArray(event.detail?.items) ? event.detail.items : [];
settingsModelCatalogItems = items.length
? items.map((item) => ({
value: item.value || item.id || '',
label: item.label || item.provider || 'Catalog model',
provider: item.provider || '',
})).filter((item) => item.value)
: SETTINGS_FALLBACK_MODELS.map((value) => ({ value, label: 'Suggested model' }));
page.querySelectorAll('[data-model-picker]').forEach((picker) => {
const panel = picker.querySelector('.model-picker-results');
if (panel && !panel.hidden) {
const input = picker.querySelector('input');
renderSettingsModelPicker(input);
}
});
});
page.addEventListener('click', (event) => {
if (event.target.closest('.secret-clear[data-target="s-anthropic"]')) {
queueMicrotask(() => {
renderClaudeCodeUi();
refreshClaudeCodeStatus();
});
}
});
byId('btn-claude-code-install')?.addEventListener('click', async () => {
applyClaudeCodeStatus({
installed: false,
ready: false,
busy: true,
message: 'Repairing Claude runtime...',
error: '',
});
try {
const resp = await apiFetch('/api/claude-code/install', { method: 'POST' });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
applyClaudeCodeStatus(data);
setStatus(data.repaired ? 'Claude runtime repaired' : 'Claude runtime up to date', 'ok');
} catch (error) {
const message = String(error?.message || error || '');
applyClaudeCodeStatus({
installed: false,
ready: false,
busy: false,
error: message,
message: `Claude runtime repair failed: ${message}`,
});
setStatus('Claude runtime repair failed', 'warn');
}
});
byId('btn-refresh-model-catalog').addEventListener('click', async () => {
await refreshModelCatalog();
});
byId('btn-reload-settings')?.addEventListener('click', async () => {
await reloadSettingsWithFeedback();
});
byId('btn-save-settings').addEventListener('click', async () => {
if (!settingsLoaded) {
setStatus('Reload current settings successfully before saving.', 'warn');
return;
}
// Validate Every-N cadence before save: malformed N must NOT silently coerce
// into a valid (e.g. every-task) cadence. Abort with a visible error instead.
if (byId('s-post-task-evolution-mode')?.value === 'every_n'
&& !/^[1-9]\d*$/.test((byId('s-evo-cadence-n')?.value || '').trim())) {
setStatus('Every-N cadence needs a whole number ≥ 1.', 'warn');
return;
}
const body = collectBody();
try {
const data = await apiClient.saveSettings(body);
let runtimeModeResult = null;
let runtimeModeError = '';
let autoGrantResult = null;
let autoGrantError = '';
let contextModeResult = null;
let contextModeError = '';
let safetyModeResult = null;
let safetyModeError = '';
try {
runtimeModeResult = await saveRuntimeModeViaNativeBridgeIfNeeded();
} catch (error) {
runtimeModeError = error.message || String(error);
}
try {
autoGrantResult = await saveAutoGrantViaNativeBridgeIfNeeded();
} catch (error) {
autoGrantError = error.message || String(error);
}
try {
contextModeResult = await saveContextModeViaOwnerEndpointIfNeeded();
} catch (error) {
contextModeError = error.message || String(error);
}
try {
safetyModeResult = await saveSafetyModeViaOwnerEndpointIfNeeded();
} catch (error) {
safetyModeError = error.message || String(error);
}
let reviewAcks = 0;
let reviewAckError = '';
try {
reviewAcks = await ackReviewCapabilityNotices(data.review_capability_notices);
} catch (error) {
reviewAckError = error.message || String(error);
}
await loadSettings();
syncAutoGrantBridgeState();
let statusMsg;
let statusType = 'ok';
if (data.no_changes) {
statusMsg = 'No changes detected';
} else if (data.restart_required) {
statusMsg = 'Settings saved. Some changes require a restart to take effect';
statusType = 'warn';
} else if (data.immediate_changed && data.next_task_changed) {
statusMsg = 'Settings saved. Some changes took effect immediately; others apply on the next task';
} else if (data.immediate_changed) {
statusMsg = 'Settings saved. Changes took effect immediately';
} else {
statusMsg = 'Settings saved. Changes take effect on the next task';
}
if (data.warnings && data.warnings.length) {
statusMsg += ' ⚠️ ' + data.warnings.join(' | ');
statusType = 'warn';
}
if (data.context_mode_downgraded) {
// The new model can't sustain Max, so context mode auto-dropped to Low.
statusMsg = `${statusMsg} ${data.notice || 'Context mode switched to Low.'}`;
statusType = 'warn';
}
if (runtimeModeResult?.restart_required) {
statusMsg = `${statusMsg} Runtime mode saved as ${runtimeModeResult.runtime_mode}; restart required.`;
statusType = 'warn';
}
if (runtimeModeError) {
statusMsg = `${statusMsg} Runtime mode was not changed: ${runtimeModeError}`;
statusType = 'warn';
}
if (autoGrantResult) {
statusMsg = `${statusMsg} Reviewed-skill auto-grant ${autoGrantResult.enabled ? 'enabled' : 'disabled'}.`;
}
if (contextModeResult?.context_mode) {
statusMsg = `${statusMsg} Context mode saved as ${contextModeResult.context_mode}.`;
}
if (contextModeError) {
statusMsg = `${statusMsg} Context mode was not changed: ${contextModeError}`;
statusType = 'warn';
}
if (safetyModeResult?.safety_mode) {
statusMsg = `${statusMsg} Safety supervisor saved as ${safetyModeResult.safety_mode}.`;
}
if (safetyModeError) {
statusMsg = `${statusMsg} Safety mode was not changed: ${safetyModeError}`;
statusType = 'warn';
}
if (autoGrantError) {
statusMsg = `${statusMsg} Reviewed-skill auto-grant was not changed: ${autoGrantError}`;
statusType = 'warn';
}
if (reviewAcks > 0) {
statusMsg = `${statusMsg} Confirmed the required context window for ${reviewAcks} scope-review route(s).`;
}
if (reviewAckError) {
statusMsg = `${statusMsg} The scope-reviewer window confirmation was not saved: ${reviewAckError}`;
statusType = 'warn';
}
setStatus(statusMsg, statusType);
window.dispatchEvent(new CustomEvent('ouro:settings-updated', { detail: { reason: 'settings saved', source: 'settings' } }));
} catch (e) {
setStatus('Failed to save: ' + e.message, 'warn');
}
});
byId('btn-reset').addEventListener('click', async () => {
const confirmedReset = await openConfirmDialog({
title: 'Reset runtime data',
body: 'This will delete all runtime data (state, memory, logs, settings) and restart.\nThe repo (agent code) will be preserved.\nYou will need to re-enter your provider settings.\n\nContinue?',
confirmLabel: 'Delete and restart',
danger: true,
});
if (!confirmedReset) return;
try {
const res = await apiFetch('/api/reset', { method: 'POST' });
const data = await res.json();
if (data.status === 'ok') {
await openConfirmDialog({
title: 'Reset complete',
body: 'Deleted: ' + (data.deleted.join(', ') || 'nothing') + '\nRestarting...',
alert: true,
});
} else {
await openConfirmDialog({
title: 'Reset failed',
body: 'Error: ' + (data.error || 'unknown'),
alert: true,
});
}
} catch (e) {
showToast('Reset failed: ' + e.message, 'error');
}
});
return {
activateTab: activateSettingsTab,
page,
};
}